From feaaf55a0c93a5da460bfc1d8fd1270fc045a49d Mon Sep 17 00:00:00 2001
From: Neil Lalonde <neillalonde@gmail.com>
Date: Wed, 16 Apr 2014 09:49:06 -0400
Subject: [PATCH] Theming: color scheme editing. Unfinished! Doesn't have any
 effect on css files yet.

---
 .../admin/components/color_input_component.js |  28 +++++
 .../admin_customize_colors_controller.js      |  76 ++++++++++++
 ...=> admin_customize_css_html_controller.js} |   4 +-
 .../javascripts/admin/models/color_scheme.js  | 115 +++++++++++++++++
 .../admin/models/color_scheme_color.js        |  71 +++++++++++
 .../routes/admin_customize_colors_route.js    |  20 +++
 .../routes/admin_customize_css_html_route.js  |  15 +++
 .../admin/routes/admin_customize_route.js     |  12 +-
 .../javascripts/admin/routes/admin_routes.js  |   5 +-
 .../admin/templates/admin.js.handlebars       |   2 +-
 .../admin/templates/customize.js.handlebars   |  76 ++----------
 .../templates/customize_colors.js.handlebars  |  56 +++++++++
 .../customize_css_html.js.handlebars          |  70 +++++++++++
 .../views/admin_customize_colors_view.js      |  11 ++
 .../admin/views/admin_customize_view.js       |   9 +-
 .../components/color-input.js.handlebars      |   1 +
 .../stylesheets/common/admin/admin_base.scss  |  23 ++++
 .../admin/color_schemes_controller.rb         |  33 +++++
 app/models/color_scheme.rb                    |  51 ++++++++
 app/models/color_scheme_color.rb              |  99 +++++++++++++++
 .../color_scheme_color_serializer.rb          |   7 ++
 app/serializers/color_scheme_serializer.rb    |   9 ++
 app/services/color_scheme_revisor.rb          |  71 +++++++++++
 config/locales/client.en.yml                  |  15 +++
 config/locales/server.en.yml                  |   3 +
 config/routes.rb                              |   5 +-
 db/fixtures/701_color_schemes.rb              |  14 +++
 .../20140416202746_create_color_schemes.rb    |  13 ++
 ...140416202801_create_color_scheme_colors.rb |  14 +++
 .../admin/color_schemes_controller_spec.rb    |  72 +++++++++++
 .../color_scheme_color_fabricator.rb          |   6 +
 spec/fabricators/color_scheme_fabricator.rb   |   5 +
 spec/models/color_scheme_spec.rb              |  43 +++++++
 spec/services/color_scheme_revisor_spec.rb    | 116 ++++++++++++++++++
 34 files changed, 1086 insertions(+), 84 deletions(-)
 create mode 100644 app/assets/javascripts/admin/components/color_input_component.js
 create mode 100644 app/assets/javascripts/admin/controllers/admin_customize_colors_controller.js
 rename app/assets/javascripts/admin/controllers/{admin_customize_controller.js => admin_customize_css_html_controller.js} (92%)
 create mode 100644 app/assets/javascripts/admin/models/color_scheme.js
 create mode 100644 app/assets/javascripts/admin/models/color_scheme_color.js
 create mode 100644 app/assets/javascripts/admin/routes/admin_customize_colors_route.js
 create mode 100644 app/assets/javascripts/admin/routes/admin_customize_css_html_route.js
 create mode 100644 app/assets/javascripts/admin/templates/customize_colors.js.handlebars
 create mode 100644 app/assets/javascripts/admin/templates/customize_css_html.js.handlebars
 create mode 100644 app/assets/javascripts/admin/views/admin_customize_colors_view.js
 create mode 100644 app/assets/javascripts/discourse/templates/components/color-input.js.handlebars
 create mode 100644 app/controllers/admin/color_schemes_controller.rb
 create mode 100644 app/models/color_scheme.rb
 create mode 100644 app/models/color_scheme_color.rb
 create mode 100644 app/serializers/color_scheme_color_serializer.rb
 create mode 100644 app/serializers/color_scheme_serializer.rb
 create mode 100644 app/services/color_scheme_revisor.rb
 create mode 100644 db/fixtures/701_color_schemes.rb
 create mode 100644 db/migrate/20140416202746_create_color_schemes.rb
 create mode 100644 db/migrate/20140416202801_create_color_scheme_colors.rb
 create mode 100644 spec/controllers/admin/color_schemes_controller_spec.rb
 create mode 100644 spec/fabricators/color_scheme_color_fabricator.rb
 create mode 100644 spec/fabricators/color_scheme_fabricator.rb
 create mode 100644 spec/models/color_scheme_spec.rb
 create mode 100644 spec/services/color_scheme_revisor_spec.rb

diff --git a/app/assets/javascripts/admin/components/color_input_component.js b/app/assets/javascripts/admin/components/color_input_component.js
new file mode 100644
index 000000000..b1626dc46
--- /dev/null
+++ b/app/assets/javascripts/admin/components/color_input_component.js
@@ -0,0 +1,28 @@
+/**
+  An input field for a color.
+
+  @param hexValue is a reference to the color's hex value.
+
+  @class Discourse.ColorInputComponent
+  @extends Ember.Component
+  @namespace Discourse
+  @module Discourse
+ **/
+Discourse.ColorInputComponent = Ember.Component.extend({
+  layoutName: 'components/color-input',
+
+  hexValueChanged: function() {
+    var hex = this.get('hexValue');
+    if (hex && (hex.length === 3 || hex.length === 6) && this.get('brightnessValue')) {
+      this.$('input').attr('style', 'color: ' + (this.get('brightnessValue') > 125 ? 'black' : 'white') + '; background-color: #' + hex + ';');
+    }
+  }.observes('hexValue', 'brightnessValue'),
+
+  didInsertElement: function() {
+    var self = this;
+    this._super();
+    Em.run.schedule('afterRender', function() {
+      self.hexValueChanged();
+    });
+  }
+});
diff --git a/app/assets/javascripts/admin/controllers/admin_customize_colors_controller.js b/app/assets/javascripts/admin/controllers/admin_customize_colors_controller.js
new file mode 100644
index 000000000..b199a9057
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin_customize_colors_controller.js
@@ -0,0 +1,76 @@
+/**
+  This controller supports interface for creating custom CSS skins in Discourse.
+
+  @class AdminCustomizeColorsController
+  @extends Ember.Controller
+  @namespace Discourse
+  @module Discourse
+**/
+Discourse.AdminCustomizeColorsController = Ember.ArrayController.extend({
+
+  baseColorScheme: function() {
+    return this.get('model').findBy('id', 1);
+  }.property('model.@each.id'),
+
+  removeSelected: function() {
+    this.removeObject(this.get('selectedItem'));
+    this.set('selectedItem', null);
+  },
+
+  actions: {
+    selectColorScheme: function(colorScheme) {
+      if (this.get('selectedItem')) { this.get('selectedItem').set('selected', false); }
+      this.set('selectedItem', colorScheme);
+      colorScheme.set('savingStatus', null);
+      colorScheme.set('selected', true);
+    },
+
+    newColorScheme: function() {
+      var newColorScheme = Em.copy(this.get('baseColorScheme'), true);
+      newColorScheme.set('name', I18n.t('admin.customize.colors.new_name'));
+      this.pushObject(newColorScheme);
+      this.send('selectColorScheme', newColorScheme);
+    },
+
+    undo: function(color) {
+      color.undo();
+    },
+
+    save: function() {
+      var selectedItem = this.get('selectedItem');
+      selectedItem.save();
+      if (selectedItem.get('enabled')) {
+        this.get('model').forEach(function(c) {
+          if (c !== selectedItem) {
+            c.set('enabled', false);
+            c.startTrackingChanges();
+            c.notifyPropertyChange('description');
+          }
+        });
+      }
+    },
+
+    copy: function(colorScheme) {
+      var newColorScheme = Em.copy(colorScheme, true);
+      newColorScheme.set('name', I18n.t('admin.customize.colors.copy_name_prefix') + ' ' + colorScheme.get('name'));
+      this.pushObject(newColorScheme);
+      this.send('selectColorScheme', newColorScheme);
+    },
+
+    destroy: function() {
+      var self = this,
+          item = self.get('selectedItem');
+
+      return bootbox.confirm(I18n.t("admin.customize.colors.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
+        if (result) {
+          if (item.get('newRecord')) {
+            self.removeSelected();
+          } else {
+            item.destroy().then(function(){ self.removeSelected(); });
+          }
+        }
+      });
+    }
+  }
+
+});
diff --git a/app/assets/javascripts/admin/controllers/admin_customize_controller.js b/app/assets/javascripts/admin/controllers/admin_customize_css_html_controller.js
similarity index 92%
rename from app/assets/javascripts/admin/controllers/admin_customize_controller.js
rename to app/assets/javascripts/admin/controllers/admin_customize_css_html_controller.js
index 9155200f5..6733bf9af 100644
--- a/app/assets/javascripts/admin/controllers/admin_customize_controller.js
+++ b/app/assets/javascripts/admin/controllers/admin_customize_css_html_controller.js
@@ -1,12 +1,12 @@
 /**
   This controller supports interface for creating custom CSS skins in Discourse.
 
-  @class AdminCustomizeController
+  @class AdminCustomizeCssHtmlController
   @extends Ember.Controller
   @namespace Discourse
   @module Discourse
 **/
-Discourse.AdminCustomizeController = Ember.ArrayController.extend({
+Discourse.AdminCustomizeCssHtmlController = Ember.ArrayController.extend({
 
   actions: {
 
diff --git a/app/assets/javascripts/admin/models/color_scheme.js b/app/assets/javascripts/admin/models/color_scheme.js
new file mode 100644
index 000000000..053cad227
--- /dev/null
+++ b/app/assets/javascripts/admin/models/color_scheme.js
@@ -0,0 +1,115 @@
+/**
+  Our data model for a color scheme.
+
+  @class ColorScheme
+  @extends Discourse.Model
+  @namespace Discourse
+  @module Discourse
+**/
+Discourse.ColorScheme = Discourse.Model.extend(Ember.Copyable, {
+
+  init: function() {
+    this._super();
+    this.startTrackingChanges();
+  },
+
+  description: function() {
+    return "" + this.name + (this.enabled ? ' (*)' : '');
+  }.property(),
+
+  startTrackingChanges: function() {
+    this.set('originals', {
+      name: this.get('name'),
+      enabled: this.get('enabled')
+    });
+  },
+
+  copy: function() {
+    var newScheme = Discourse.ColorScheme.create({name: this.get('name'), enabled: false, can_edit: true, colors: Em.A()});
+    _.each(this.get('colors'), function(c){
+      newScheme.colors.pushObject(Discourse.ColorSchemeColor.create({name: c.get('name'), hex: c.get('hex'), opacity: c.get('opacity')}));
+    });
+    return newScheme;
+  },
+
+  changed: function() {
+    if (!this.originals) return false;
+    if (this.originals['name'] !== this.get('name') || this.originals['enabled'] !== this.get('enabled')) return true;
+    if (_.any(this.get('colors'), function(c){ return c.get('changed'); })) return true;
+    return false;
+  }.property('name', 'enabled', 'colors.@each.changed', 'saving'),
+
+  disableSave: function() {
+    return !this.get('changed') || this.get('saving');
+  }.property('changed'),
+
+  newRecord: function() {
+    return (!this.get('id'));
+  }.property('id'),
+
+  save: function() {
+    var self = this;
+    this.set('savingStatus', I18n.t('saving'));
+    this.set('saving',true);
+
+    var data = { name: this.name, enabled: this.enabled };
+
+    data.colors = [];
+    _.each(this.get('colors'), function(c) {
+      if (!self.id || c.get('changed')) {
+        data.colors.pushObject({name: c.get('name'), hex: c.get('hex'), opacity: c.get('opacity')});
+      }
+    });
+
+    return Discourse.ajax("/admin/color_schemes" + (this.id ? '/' + this.id : '') + '.json', {
+      data: JSON.stringify({"color_scheme": data}),
+      type: this.id ? 'PUT' : 'POST',
+      dataType: 'json',
+      contentType: 'application/json'
+    }).then(function(result) {
+      if(result.id) { self.set('id', result.id); }
+      self.startTrackingChanges();
+      _.each(self.get('colors'), function(c) {
+        c.startTrackingChanges();
+      });
+      self.set('savingStatus', I18n.t('saved'));
+      self.set('saving', false);
+      self.notifyPropertyChange('description');
+    });
+  },
+
+  destroy: function() {
+    if (this.id) {
+      return Discourse.ajax("/admin/color_schemes/" + this.id, { type: 'DELETE' });
+    }
+  }
+
+});
+
+var ColorSchemes = Ember.ArrayProxy.extend({
+  selectedItemChanged: function() {
+    var selected = this.get('selectedItem');
+    _.each(this.get('content'),function(i) {
+      return i.set('selected', selected === i);
+    });
+  }.observes('selectedItem')
+});
+
+Discourse.ColorScheme.reopenClass({
+  findAll: function() {
+    var colorSchemes = ColorSchemes.create({ content: [], loading: true });
+    Discourse.ajax('/admin/color_schemes').then(function(all) {
+      _.each(all, function(colorScheme){
+        colorSchemes.pushObject(Discourse.ColorScheme.create({
+          id: colorScheme.id,
+          name: colorScheme.name,
+          enabled: colorScheme.enabled,
+          can_edit: colorScheme.can_edit,
+          colors: colorScheme.colors.map(function(c) { return Discourse.ColorSchemeColor.create({name: c.name, hex: c.hex, opacity: c.opacity}); })
+        }));
+      });
+      colorSchemes.set('loading', false);
+    });
+    return colorSchemes;
+  }
+});
diff --git a/app/assets/javascripts/admin/models/color_scheme_color.js b/app/assets/javascripts/admin/models/color_scheme_color.js
new file mode 100644
index 000000000..9c6420a75
--- /dev/null
+++ b/app/assets/javascripts/admin/models/color_scheme_color.js
@@ -0,0 +1,71 @@
+/**
+  Our data model for a color within a color scheme.
+  (It's a funny name for a class, but Color seemed too generic for what this class is.)
+
+  @class ColorSchemeColor
+  @extends Discourse.Model
+  @namespace Discourse
+  @module Discourse
+**/
+Discourse.ColorSchemeColor = Discourse.Model.extend({
+
+  init: function() {
+    this._super();
+    this.startTrackingChanges();
+  },
+
+  startTrackingChanges: function() {
+    this.set('originals', {
+      hex: this.get('hex') || 'FFFFFF',
+      opacity: this.get('opacity') || '100'
+    });
+    this.notifyPropertyChange('hex'); // force changed property to be recalculated
+  },
+
+  changed: function() {
+    if (!this.originals) return false;
+
+    if (this.get('hex') !== this.originals['hex'] || this.get('opacity').toString() !== this.originals['opacity'].toString()) {
+      return true;
+    } else {
+      return false;
+    }
+  }.property('hex', 'opacity'),
+
+  undo: function() {
+    if (this.originals) {
+      this.set('hex',     this.originals['hex']);
+      this.set('opacity', this.originals['opacity']);
+    }
+  },
+
+  /**
+    brightness returns a number between 0 (darkest) to 255 (brightest).
+    Undefined if hex is not a valid color.
+
+    @property brightness
+  **/
+  brightness: function() {
+    var hex = this.get('hex');
+    if (hex.length === 6 || hex.length === 3) {
+      if (hex.length === 3) {
+        hex = hex.substr(0,1) + hex.substr(0,1) + hex.substr(1,1) + hex.substr(1,1) + hex.substr(2,1) + hex.substr(2,1);
+      }
+      return Math.round(((parseInt('0x'+hex.substr(0,2)) * 299) + (parseInt('0x'+hex.substr(2,2)) * 587) + (parseInt('0x'+hex.substr(4,2)) * 114)) /1000);
+    }
+  }.property('hex'),
+
+  hexValueChanged: function() {
+    if (this.get('hex')) {
+      this.set('hex', this.get('hex').toString().replace(/[^0-9a-fA-F]/g, ""));
+    }
+  }.observes('hex'),
+
+  opacityChanged: function() {
+    if (this.get('opacity')) {
+      var o = this.get('opacity').toString().replace(/[^\d.]/g, "");
+      if (parseInt(o,10) > 100) { o = o.substr(0,o.length-1); }
+      this.set('opacity', o);
+    }
+  }.observes('opacity')
+});
diff --git a/app/assets/javascripts/admin/routes/admin_customize_colors_route.js b/app/assets/javascripts/admin/routes/admin_customize_colors_route.js
new file mode 100644
index 000000000..953031ab4
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin_customize_colors_route.js
@@ -0,0 +1,20 @@
+/**
+  Handles routes related to colors customization
+
+  @class AdminCustomizeColorsRoute
+  @extends Discourse.Route
+  @namespace Discourse
+  @module Discourse
+**/
+Discourse.AdminCustomizeColorsRoute = Discourse.Route.extend({
+
+  model: function() {
+    return Discourse.ColorScheme.findAll();
+  },
+
+  deactivate: function() {
+    this._super();
+    this.controllerFor('adminCustomizeColors').set('selectedItem', null);
+  },
+
+});
diff --git a/app/assets/javascripts/admin/routes/admin_customize_css_html_route.js b/app/assets/javascripts/admin/routes/admin_customize_css_html_route.js
new file mode 100644
index 000000000..ca4594423
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin_customize_css_html_route.js
@@ -0,0 +1,15 @@
+/**
+  Handles routes related to css/html customization
+
+  @class AdminCustomizeCssHtmlRoute
+  @extends Discourse.Route
+  @namespace Discourse
+  @module Discourse
+**/
+Discourse.AdminCustomizeCssHtmlRoute = Discourse.Route.extend({
+
+  model: function() {
+    return Discourse.SiteCustomization.findAll();
+  }
+
+});
diff --git a/app/assets/javascripts/admin/routes/admin_customize_route.js b/app/assets/javascripts/admin/routes/admin_customize_route.js
index 59e26166c..d7eca41d8 100644
--- a/app/assets/javascripts/admin/routes/admin_customize_route.js
+++ b/app/assets/javascripts/admin/routes/admin_customize_route.js
@@ -1,15 +1,13 @@
 /**
   Handles routes related to customization
 
-  @class AdminCustomizeRoute
+  @class AdminCustomizeIndexRoute
   @extends Discourse.Route
   @namespace Discourse
   @module Discourse
 **/
-Discourse.AdminCustomizeRoute = Discourse.Route.extend({
-
-  model: function() {
-    return Discourse.SiteCustomization.findAll();
+Discourse.AdminCustomizeIndexRoute = Discourse.Route.extend({
+  redirect: function() {
+    this.transitionTo('adminCustomize.css_html');
   }
-
-});
+});
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/routes/admin_routes.js b/app/assets/javascripts/admin/routes/admin_routes.js
index bc2a431a2..71f687963 100644
--- a/app/assets/javascripts/admin/routes/admin_routes.js
+++ b/app/assets/javascripts/admin/routes/admin_routes.js
@@ -22,7 +22,10 @@ Discourse.Route.buildRoutes(function() {
       this.route('previewDigest', { path: '/preview-digest' });
     });
 
-    this.route('customize');
+    this.resource('adminCustomize', { path: '/customize' } ,function() {
+      this.route('colors');
+      this.route('css_html');
+    });
     this.route('api');
 
     this.resource('admin.backups', { path: '/backups' }, function() {
diff --git a/app/assets/javascripts/admin/templates/admin.js.handlebars b/app/assets/javascripts/admin/templates/admin.js.handlebars
index 84b4ca72c..85a695066 100644
--- a/app/assets/javascripts/admin/templates/admin.js.handlebars
+++ b/app/assets/javascripts/admin/templates/admin.js.handlebars
@@ -20,7 +20,7 @@
         <li>{{#link-to 'adminFlags'}}{{i18n admin.flags.title}}{{/link-to}}</li>
         <li>{{#link-to 'adminLogs'}}{{i18n admin.logs.title}}{{/link-to}}</li>
         {{#if currentUser.admin}}
-          <li>{{#link-to 'admin.customize'}}{{i18n admin.customize.title}}{{/link-to}}</li>
+          <li>{{#link-to 'adminCustomize'}}{{i18n admin.customize.title}}{{/link-to}}</li>
           <li>{{#link-to 'admin.api'}}{{i18n admin.api.title}}{{/link-to}}</li>
           <li>{{#link-to 'admin.backups'}}{{i18n admin.backups.title}}{{/link-to}}</li>
         {{/if}}
diff --git a/app/assets/javascripts/admin/templates/customize.js.handlebars b/app/assets/javascripts/admin/templates/customize.js.handlebars
index b4150ca11..acc2f5ecf 100644
--- a/app/assets/javascripts/admin/templates/customize.js.handlebars
+++ b/app/assets/javascripts/admin/templates/customize.js.handlebars
@@ -1,70 +1,12 @@
-<div class='content-list span6'>
-  <h3>{{i18n admin.customize.long_title}}</h3>
-  <ul>
-    {{#each model}}
-    <li><a {{action selectStyle this}} {{bind-attr class="this.selected:active"}}>{{this.description}}</a></li>
-    {{/each}}
-  </ul>
-  <button {{action newCustomization}} class='btn'>{{i18n admin.customize.new}}</button>
+<div class='admin-controls'>
+  <div class='span15'>
+    <ul class="nav nav-pills">
+      <li>{{#link-to 'adminCustomize.colors'}}{{i18n admin.customize.colors.title}}{{/link-to}}</li>
+      <li>{{#link-to 'adminCustomize.css_html'}}{{i18n admin.customize.css_html.title}}{{/link-to}}</li>
+    </ul>
+  </div>
 </div>
 
-
-{{#if selectedItem}}
-<div class='current-style'>
-  {{#with selectedItem}}
-    {{textField class="style-name" value=name}}
-
-    <div class='admin-controls'>
-      <ul class="nav nav-pills">
-        <li>
-          <a {{bind-attr class="view.stylesheetActive:active"}}{{action selectStylesheet href="true" target="view"}}>{{i18n admin.customize.css}}</a>
-        </li>
-        <li>
-          <a {{bind-attr class="view.headerActive:active"}}{{action selectHeader href="true" target="view"}}>{{i18n admin.customize.header}}</a>
-        </li>
-        <li>
-          <a {{bind-attr class="view.mobileStylesheetActive:active"}}{{action selectMobileStylesheet href="true" target="view"}}>{{i18n admin.customize.mobile_css}}</a>
-        </li>
-        <li>
-          <a {{bind-attr class="view.mobileHeaderActive:active"}}{{action selectMobileHeader href="true" target="view"}}>{{i18n admin.customize.mobile_header}}</a>
-        </li>
-      </ul>
-    </div>
-
-    <div class="admin-container">
-      {{#if view.headerActive}}
-        {{aceEditor content=header mode="html"}}
-      {{/if}}
-      {{#if view.stylesheetActive}}
-        {{aceEditor content=stylesheet mode="scss"}}
-      {{/if}}
-      {{#if view.mobileHeaderActive}}
-        {{aceEditor content=mobile_header mode="html"}}
-      {{/if}}
-      {{#if view.mobileStylesheetActive}}
-        {{aceEditor content=mobile_stylesheet mode="scss"}}
-      {{/if}}
-    </div>
-  {{/with}}
-  <br>
-  <div class='status-actions'>
-    <span>{{i18n admin.customize.override_default}} {{view Ember.Checkbox checkedBinding="selectedItem.override_default_style"}}</span>
-    <span>{{i18n admin.customize.enabled}}  {{view Ember.Checkbox checkedBinding="selectedItem.enabled"}}</span>
-    {{#unless selectedItem.changed}}
-    <a class='preview-link' {{bind-attr href="selectedItem.previewUrl"}} target='_blank'>{{i18n admin.customize.preview}}</a>
-    |
-    <a href="/?preview-style=" target='_blank'>{{i18n admin.customize.undo_preview}}</a><br>
-    {{/unless}}
-  </div>
-
-  <div class='buttons'>
-    <button {{action save}} {{bind-attr disabled="selectedItem.disableSave"}} class='btn'>{{i18n admin.customize.save}}</button>
-    <span class='saving'>{{selectedItem.savingStatus}}</span>
-    <a {{action destroy}} class='delete-link'>{{i18n admin.customize.delete}}</a>
-  </div>
-
+<div class="admin-container">
+  {{outlet}}
 </div>
-{{else}}
-  <p class="about">{{i18n admin.customize.about}}</p>
-{{/if}}
-<div class='clearfix'></div>
diff --git a/app/assets/javascripts/admin/templates/customize_colors.js.handlebars b/app/assets/javascripts/admin/templates/customize_colors.js.handlebars
new file mode 100644
index 000000000..9cdeebc2f
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/customize_colors.js.handlebars
@@ -0,0 +1,56 @@
+<div class='alert alert-error'>{{i18n admin.customize.colors.under_construction}}</div>
+
+<div class='content-list span6'>
+  <h3>{{i18n admin.customize.colors.long_title}}</h3>
+  <ul>
+    {{#each model}}
+      {{#if can_edit}}
+        <li><a {{action selectColorScheme this}} {{bind-attr class="selected:active"}}>{{description}}</a></li>
+      {{/if}}
+    {{/each}}
+  </ul>
+  <button {{action newColorScheme}} class='btn'>{{i18n admin.customize.new}}</button>
+</div>
+
+{{#if selectedItem}}
+  {{#with selectedItem}}
+    <div class="current-style color-scheme">
+      <div class="admin-container">
+        <h1>{{textField class="style-name" value=name}}</h1>
+
+        <div class="controls">
+          <button {{action save}} {{bind-attr disabled="disableSave"}} class='btn'>{{i18n admin.customize.save}}</button>
+          <span {{bind-attr class=":saving savingStatus::hidden" }}>{{savingStatus}}</span>
+          <button {{action copy this}} class='btn'><i class="fa fa-copy"></i> {{i18n admin.customize.copy}}</button>
+          <span>{{view Ember.Checkbox checkedBinding="enabled"}} {{i18n admin.customize.enabled}}</span>
+          <a {{action destroy}} class='delete-link'>{{i18n admin.customize.delete}}</a>
+        </div>
+
+        <table class="table colors">
+          <thead>
+            <tr>
+              <th></th>
+              <th class="hex">{{i18n admin.customize.color}}</th>
+              <th class="opacity">{{i18n admin.customize.opacity}}</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            {{#each colors}}
+              <tr {{bind-attr class="changed"}}>
+                <td class="name">{{name}}</td>
+                <td class="hex">{{color-input hexValue=hex brightnessValue=brightness}}</td>
+                <td class="opacity">{{textField class="opacity-input" value=opacity maxlength="3"}}</td>
+                <td class="changed">
+                  <button {{bind-attr class=":btn :undo changed::invisible"}} {{action undo this}}>undo</button>
+                </td>
+              </tr>
+            {{/each}}
+          </tbody>
+        </table>
+      </div>
+    </div>
+  {{/with}}
+{{else}}
+  <p class="about">{{i18n admin.customize.colors.about}}</p>
+{{/if}}
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/templates/customize_css_html.js.handlebars b/app/assets/javascripts/admin/templates/customize_css_html.js.handlebars
new file mode 100644
index 000000000..5042cfa00
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/customize_css_html.js.handlebars
@@ -0,0 +1,70 @@
+<div class='content-list span6'>
+  <h3>{{i18n admin.customize.css_html.long_title}}</h3>
+  <ul>
+    {{#each model}}
+    <li><a {{action selectStyle this}} {{bind-attr class="this.selected:active"}}>{{this.description}}</a></li>
+    {{/each}}
+  </ul>
+  <button {{action newCustomization}} class='btn'>{{i18n admin.customize.new}}</button>
+</div>
+
+
+{{#if selectedItem}}
+<div class='current-style'>
+  {{#with selectedItem}}
+    {{textField class="style-name" value=name}}
+
+    <div class='admin-controls'>
+      <ul class="nav nav-pills">
+        <li>
+          <a {{bind-attr class="view.stylesheetActive:active"}}{{action selectStylesheet href="true" target="view"}}>{{i18n admin.customize.css}}</a>
+        </li>
+        <li>
+          <a {{bind-attr class="view.headerActive:active"}}{{action selectHeader href="true" target="view"}}>{{i18n admin.customize.header}}</a>
+        </li>
+        <li>
+          <a {{bind-attr class="view.mobileStylesheetActive:active"}}{{action selectMobileStylesheet href="true" target="view"}}>{{i18n admin.customize.mobile_css}}</a>
+        </li>
+        <li>
+          <a {{bind-attr class="view.mobileHeaderActive:active"}}{{action selectMobileHeader href="true" target="view"}}>{{i18n admin.customize.mobile_header}}</a>
+        </li>
+      </ul>
+    </div>
+
+    <div class="admin-container">
+      {{#if view.headerActive}}
+        {{aceEditor content=header mode="html"}}
+      {{/if}}
+      {{#if view.stylesheetActive}}
+        {{aceEditor content=stylesheet mode="scss"}}
+      {{/if}}
+      {{#if view.mobileHeaderActive}}
+        {{aceEditor content=mobile_header mode="html"}}
+      {{/if}}
+      {{#if view.mobileStylesheetActive}}
+        {{aceEditor content=mobile_stylesheet mode="scss"}}
+      {{/if}}
+    </div>
+  {{/with}}
+  <br>
+  <div class='status-actions'>
+    <span>{{i18n admin.customize.override_default}} {{view Ember.Checkbox checkedBinding="selectedItem.override_default_style"}}</span>
+    <span>{{i18n admin.customize.enabled}}  {{view Ember.Checkbox checkedBinding="selectedItem.enabled"}}</span>
+    {{#unless selectedItem.changed}}
+    <a class='preview-link' {{bind-attr href="selectedItem.previewUrl"}} target='_blank'>{{i18n admin.customize.preview}}</a>
+    |
+    <a href="/?preview-style=" target='_blank'>{{i18n admin.customize.undo_preview}}</a><br>
+    {{/unless}}
+  </div>
+
+  <div class='buttons'>
+    <button {{action save}} {{bind-attr disabled="selectedItem.disableSave"}} class='btn'>{{i18n admin.customize.save}}</button>
+    <span class='saving'>{{selectedItem.savingStatus}}</span>
+    <a {{action destroy}} class='delete-link'>{{i18n admin.customize.delete}}</a>
+  </div>
+
+</div>
+{{else}}
+  <p class="about">{{i18n admin.customize.about}}</p>
+{{/if}}
+<div class='clearfix'></div>
diff --git a/app/assets/javascripts/admin/views/admin_customize_colors_view.js b/app/assets/javascripts/admin/views/admin_customize_colors_view.js
new file mode 100644
index 000000000..e503a6f6d
--- /dev/null
+++ b/app/assets/javascripts/admin/views/admin_customize_colors_view.js
@@ -0,0 +1,11 @@
+/**
+  A view to handle color selections within a site customization
+
+  @class AdminCustomizeColorsView
+  @extends Discourse.View
+  @namespace Discourse
+  @module Discourse
+**/
+Discourse.AdminCustomizeColorsView = Discourse.View.extend({
+  templateName: 'admin/templates/customize_colors'
+});
diff --git a/app/assets/javascripts/admin/views/admin_customize_view.js b/app/assets/javascripts/admin/views/admin_customize_view.js
index 767559bf7..de6b7eab9 100644
--- a/app/assets/javascripts/admin/views/admin_customize_view.js
+++ b/app/assets/javascripts/admin/views/admin_customize_view.js
@@ -12,15 +12,14 @@ Discourse.AdminCustomizeView = Discourse.View.extend({
   templateName: 'admin/templates/customize',
   classNames: ['customize'],
   selected: 'stylesheet',
-  headerActive:     Em.computed.equal('selected', 'header'),
-  stylesheetActive: Em.computed.equal('selected', 'stylesheet'),
+  headerActive:           Em.computed.equal('selected', 'header'),
+  stylesheetActive:       Em.computed.equal('selected', 'stylesheet'),
   mobileHeaderActive:     Em.computed.equal('selected', 'mobileHeader'),
   mobileStylesheetActive: Em.computed.equal('selected', 'mobileStylesheet'),
 
   actions: {
-    selectHeader:     function() { this.set('selected', 'header'); },
-    selectStylesheet: function() { this.set('selected', 'stylesheet'); },
-
+    selectHeader:           function() { this.set('selected', 'header'); },
+    selectStylesheet:       function() { this.set('selected', 'stylesheet'); },
     selectMobileHeader:     function() { this.set('selected', 'mobileHeader'); },
     selectMobileStylesheet: function() { this.set('selected', 'mobileStylesheet'); }
   },
diff --git a/app/assets/javascripts/discourse/templates/components/color-input.js.handlebars b/app/assets/javascripts/discourse/templates/components/color-input.js.handlebars
new file mode 100644
index 000000000..18e2b107c
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/color-input.js.handlebars
@@ -0,0 +1 @@
+{{textField class="hex-input" value=hexValue maxlength="6"}}
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index 8010dcf0a..6412c8369 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -421,6 +421,25 @@ section.details {
       }
     }
   }
+  .color-scheme {
+    .controls {
+      span, button, a {
+        margin-right: 10px;
+      }
+    }
+  }
+  .colors {
+    thead th       { border: none; }
+    td.hex         { width: 100px; }
+    .hex-input     { width: 80px; }
+    td.opacity     { width: 50px; }
+    .opacity-input { width: 30px; }
+    .hex, .opacity { text-align: center; }
+
+    .changed .name {
+      color: darken($highlight_text_color, 30%);
+    }
+  }
 }
 
 
@@ -790,12 +809,16 @@ table.api-keys {
     color: $primary_text_color;
     font-size: 15px;
     padding-left: 5px;
+    margin-bottom: 10px;
   }
 
   ul {
     list-style: none;
     margin: 0;
 
+    li:first-of-type {
+      border-top: 1px solid $primary_border_color;
+    }
     li {
       border-bottom: 1px solid $primary_border_color;
     }
diff --git a/app/controllers/admin/color_schemes_controller.rb b/app/controllers/admin/color_schemes_controller.rb
new file mode 100644
index 000000000..569391bb5
--- /dev/null
+++ b/app/controllers/admin/color_schemes_controller.rb
@@ -0,0 +1,33 @@
+class Admin::ColorSchemesController < Admin::AdminController
+
+  before_filter :fetch_color_scheme, only: [:update, :destroy]
+
+  def index
+    render_serialized(ColorScheme.current_version.order('id ASC').all.to_a, ColorSchemeSerializer)
+  end
+
+  def create
+    color_scheme = ColorScheme.create(color_scheme_params)
+    render json: color_scheme, root: false
+  end
+
+  def update
+    render json: ColorSchemeRevisor.revise(@color_scheme, color_scheme_params), root: false
+  end
+
+  def destroy
+    @color_scheme.destroy
+    render json: success_json
+  end
+
+
+  private
+
+  def fetch_color_scheme
+    @color_scheme = ColorScheme.find(params[:id])
+  end
+
+  def color_scheme_params
+    params.permit(color_scheme: [:enabled, :name, colors: [:name, :hex, :opacity]])[:color_scheme]
+  end
+end
diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb
new file mode 100644
index 000000000..b1f9b000a
--- /dev/null
+++ b/app/models/color_scheme.rb
@@ -0,0 +1,51 @@
+class ColorScheme < ActiveRecord::Base
+
+  has_many :color_scheme_colors, -> { order('id ASC') }, dependent: :destroy
+
+  alias_method :colors, :color_scheme_colors
+
+  scope :current_version, ->{ where(versioned_id: nil) }
+
+  after_destroy :destroy_versions
+
+  def self.enabled
+    current_version.where(enabled: true).first || find(1)
+  end
+
+  def can_edit?
+    self.id != 1 # base theme shouldn't be edited, except by seed data
+  end
+
+  def colors=(arr)
+    @colors_by_name = nil
+    arr.each do |c|
+      self.color_scheme_colors << ColorSchemeColor.new(
+        name: c[:name],
+        hex: c[:hex],
+        opacity: c[:opacity].to_i
+      )
+    end
+  end
+
+  def colors_by_name
+    @colors_by_name ||= self.colors.inject({}) { |sum,c| sum[c.name] = c; sum; }
+  end
+  def clear_colors_cache
+    @colors_by_name = nil
+  end
+
+  def colors_hashes
+    color_scheme_colors.map do |c|
+      {name: c.name, hex: c.hex, opacity: c.opacity}
+    end
+  end
+
+  def previous_version
+    ColorScheme.where(versioned_id: self.id).where('version < ?', self.version).order('version DESC').first
+  end
+
+  def destroy_versions
+    ColorScheme.where(versioned_id: self.id).destroy_all
+  end
+
+end
diff --git a/app/models/color_scheme_color.rb b/app/models/color_scheme_color.rb
new file mode 100644
index 000000000..4bcb5b001
--- /dev/null
+++ b/app/models/color_scheme_color.rb
@@ -0,0 +1,99 @@
+class ColorSchemeColor < ActiveRecord::Base
+  belongs_to :color_scheme
+
+  BASE_COLORS = {
+    primary_border_color: "e6e6e6",
+    secondary_border_color: "f5f5f5",
+    tertiary_border_color: "ffffff",
+    highlight_border_color: "ffff4d",
+    emphasis_border_color: "00aaff",
+    warning_border_color: "f0a28f",
+    success_border_color: "009900",
+    primary_background_color: "ffffff",
+    secondary_background_color: "333333",
+    tertiary_background_color: "4d4d4d",
+    moderator_background_color: "ffffe5",
+    emphasis_background_color: "e5f6ff",
+    success_background_color: "99ff99",
+    warning_background_color: "f6c7bc",
+    highlight_background_color: "ffffc2",
+    like_background_color: "fee7ed",
+    composer_background_color: "e6e6e6",
+    notification_badge_background_color: "8c8c8c",
+    primary_text_color: "333333",
+    secondary_text_color: "999999",
+    tertiary_text_color: "ffffff",
+    warning_text_color: "e45735",
+    success_text_color: "009900",
+    emphasis_text_color: "00aaff",
+    highlight_text_color: "ffff4d",
+    like_color: "fa6c8d",
+    primary_shadow_color: "333333",
+    secondary_shadow_color: "ffffff",
+    warning_shadow_color: "f0a28f",
+    success_shadow_color: "009900",
+    highlight: "ffff4d",
+    header_item_highlight: "e5f6ff",
+    link_color: "0088cc",
+    secondary_link_color: "ffffff",
+    "muted-link-color" => "8c8c8c",
+    "muted-important-link-color" => "8c8c8c",
+    "link-color-visited" => "0088cc",
+    "link-color-hover" => "0088cc",
+    "link-color-active" => "0088cc",
+    heatmap_high: "ea7c62",
+    heatmap_med: "e45735",
+    heatmap_low: "cb3d1b",
+    coldmap_high: "33bbff",
+    coldmap_med: "00aaff",
+    coldmap_low: "0088cc",
+    "btn-default-color" => "333333",
+    "btn-default-background-color" => "b3b3b3",
+    "btn-default-background-color-hover" => "f5f5f5",
+    "btn-primary-border-color" => "0088cc",
+    "btn-primary-background-color" => "00aaff",
+    "btn-primary-background-color-dark" => "00aaff",
+    "btn-primary-background-color-light" => "99ddff",
+    "btn-danger-border-color" => "cb3d1b",
+    "btn-danger-background-color" => "e45735",
+    "btn-danger-background-color-dark" => "cb3d1b",
+    "btn-danger-background-color-light" => "e45735",
+    "btn-success-background" => "00b300",
+    "nav-pills-color" => "333333",
+    "nav-pills-color-hover" => "e45735",
+    "nav-pills-border-color-hover" => "f9dad2",
+    "nav-pills-background-color-hover" => "f9dad2",
+    "nav-pills-color-active" => "e45735",
+    "nav-pills-border-color-active" => "cb3d1b",
+    "nav-pills-background-color-active" => "e45735",
+    "nav-stacked-color" => "333333",
+    "nav-stacked-border-color" => "cccccc",
+    "nav-stacked-background-color" => "f5f5f5",
+    "nav-stacked-divider-color" => "cccccc",
+    "nav-stacked-chevron-color" => "b3b3b3",
+    "nav-stacked-border-color-active" => "e45735",
+    "nav-stacked-background-color-active" => "e45735",
+    "nav-button-color-hover" => "333333",
+    "nav-button-background-color-hover" => "cccccc",
+    "nav-button-color-active" => "333333",
+    "nav-button-background-color-active" => "cccccc",
+    "modal-header-color" => "e45735",
+    "modal-header-border-color" => "b3b3b3",
+    "modal-close-button-color" => "b3b3b3",
+    "nav-like-button-color-hover" => "fa6c8d",
+    "nav-like-button-background-color-hover" => "fed9e1",
+    "nav-like-button-color-active" => "f83b67",
+    "nav-like-button-background-color-active" => "fed9e1",
+    "topic-list-border-color" => "b3b3b3",
+    "topic-list-th-color" => "8c8c8c",
+    "topic-list-th-border-color" => "b3b3b3",
+    "topic-list-th-background-color" => "f5f5f5",
+    "topic-list-td-color" => "8c8c8c",
+    "topic-list-td-border-color" => "cccccc",
+    "topic-list-star-color" => "cccccc",
+    "topic-list-starred-color" => "e45735",
+    "quote-background" => "f5f5f5",
+    topicMenuColor: "333333",
+    bookmarkColor: "00aaff"
+  }
+end
diff --git a/app/serializers/color_scheme_color_serializer.rb b/app/serializers/color_scheme_color_serializer.rb
new file mode 100644
index 000000000..53544688f
--- /dev/null
+++ b/app/serializers/color_scheme_color_serializer.rb
@@ -0,0 +1,7 @@
+class ColorSchemeColorSerializer < ApplicationSerializer
+  attributes :name, :hex, :opacity
+
+  def hex
+    object.hex # otherwise something crazy is returned
+  end
+end
diff --git a/app/serializers/color_scheme_serializer.rb b/app/serializers/color_scheme_serializer.rb
new file mode 100644
index 000000000..75394fa5e
--- /dev/null
+++ b/app/serializers/color_scheme_serializer.rb
@@ -0,0 +1,9 @@
+class ColorSchemeSerializer < ApplicationSerializer
+  attributes :id, :name, :enabled, :can_edit
+
+  has_many :colors, serializer: ColorSchemeColorSerializer, embed: :objects
+
+  def can_edit
+    object.can_edit?
+  end
+end
\ No newline at end of file
diff --git a/app/services/color_scheme_revisor.rb b/app/services/color_scheme_revisor.rb
new file mode 100644
index 000000000..32dffcd1d
--- /dev/null
+++ b/app/services/color_scheme_revisor.rb
@@ -0,0 +1,71 @@
+class ColorSchemeRevisor
+
+  def initialize(color_scheme, params={})
+    @color_scheme = color_scheme
+    @params = params
+  end
+
+  def self.revise(color_scheme, params)
+    self.new(color_scheme, params).revise
+  end
+
+  def self.revert(color_scheme)
+    self.new(color_scheme).revert
+  end
+
+  def revise
+    ColorScheme.transaction do
+      if @params[:enabled]
+        ColorScheme.update_all enabled: false
+      end
+
+      @color_scheme.name    = @params[:name]
+      @color_scheme.enabled = @params[:enabled]
+      new_version = false
+
+      if @params[:colors]
+        new_version = @params[:colors].any? do |c|
+          (existing = @color_scheme.colors_by_name[c[:name]]).nil? or existing.hex != c[:hex] or existing.opacity != c[:opacity]
+        end
+      end
+
+      if new_version
+        old_version = ColorScheme.create(
+          name: @color_scheme.name,
+          enabled: false,
+          colors: @color_scheme.colors_hashes,
+          versioned_id: @color_scheme.id,
+          version: @color_scheme.version)
+        @color_scheme.version += 1
+      end
+
+      if @params[:colors]
+        @params[:colors].each do |c|
+          if existing = @color_scheme.colors_by_name[c[:name]]
+            existing.update_attributes(c)
+          end
+        end
+      end
+
+      @color_scheme.save!
+      @color_scheme.clear_colors_cache
+    end
+    @color_scheme
+  end
+
+  def revert
+    ColorScheme.transaction do
+      if prev = @color_scheme.previous_version
+        @color_scheme.version = prev.version
+        @color_scheme.colors.clear
+        prev.colors.update_all(color_scheme_id: @color_scheme.id)
+        prev.destroy
+        @color_scheme.save!
+        @color_scheme.clear_colors_cache
+      end
+    end
+
+    @color_scheme
+  end
+
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 0628e3efd..89b96f933 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1445,6 +1445,21 @@ en:
         delete: "Delete"
         delete_confirm: "Delete this customization?"
         about: "Site Customization allow you to modify stylesheets and headers on the site. Choose or add one to start editing."
+        color: "Color"
+        opacity: "Opacity"
+        copy: "Copy"
+        css_html:
+          title: "CSS, HTML"
+          long_title: "CSS and HTML Customizations"
+        colors:
+          title: "Colors"
+          long_title: "Color Schemes"
+          about: "Color schemes allow you to modify the colors used on the site without writing CSS. Choose or add one to start."
+          new_name: "New Color Scheme"
+          copy_name_prefix: "Copy of"
+          delete_confirm: "Delete this color scheme?"
+          under_construction: "NOTE: Changes made here will do nothing! This feature is under construction!"
+
 
       email:
         title: "Email"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 7a3d729f1..fbb452fc1 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1491,3 +1491,6 @@ en:
     message_to_blank: "message.to is blank"
     text_part_body_blank: "text_part.body is blank"
     body_blank: "body is blank"
+
+  color_schemes:
+    base_theme_name: "Base"
diff --git a/config/routes.rb b/config/routes.rb
index 148a1519a..7349256d2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -84,7 +84,9 @@ Discourse::Application.routes.draw do
       resources :screened_urls,         only: [:index]
     end
 
-    get "customize" => "site_customizations#index", constraints: AdminConstraint.new
+    get "customize" => "color_schemes#index", constraints: AdminConstraint.new
+    get "customize/css_html" => "site_customizations#index", constraints: AdminConstraint.new
+    get "customize/colors" => "color_schemes#index", constraints: AdminConstraint.new
     get "flags" => "flags#index"
     get "flags/:filter" => "flags#index"
     post "flags/agree/:id" => "flags#agree"
@@ -93,6 +95,7 @@ Discourse::Application.routes.draw do
     resources :site_customizations, constraints: AdminConstraint.new
     resources :site_contents, constraints: AdminConstraint.new
     resources :site_content_types, constraints: AdminConstraint.new
+    resources :color_schemes, constraints: AdminConstraint.new
 
     get "version_check" => "versions#show"
 
diff --git a/db/fixtures/701_color_schemes.rb b/db/fixtures/701_color_schemes.rb
new file mode 100644
index 000000000..bd0c36605
--- /dev/null
+++ b/db/fixtures/701_color_schemes.rb
@@ -0,0 +1,14 @@
+ColorScheme.seed do |s|
+  s.id = 1
+  s.name = I18n.t("color_schemes.base_theme_name")
+  s.enabled = false
+end
+
+ColorSchemeColor::BASE_COLORS.each_with_index do |color, i|
+  ColorSchemeColor.seed do |c|
+    c.id = i+1
+    c.name = color[0]
+    c.hex = color[1]
+    c.color_scheme_id = 1
+  end
+end
diff --git a/db/migrate/20140416202746_create_color_schemes.rb b/db/migrate/20140416202746_create_color_schemes.rb
new file mode 100644
index 000000000..e6491c0b9
--- /dev/null
+++ b/db/migrate/20140416202746_create_color_schemes.rb
@@ -0,0 +1,13 @@
+class CreateColorSchemes < ActiveRecord::Migration
+  def change
+    create_table :color_schemes do |t|
+      t.string  :name,         null: false
+      t.boolean :enabled,      null: false, default: false
+
+      t.integer :versioned_id
+      t.integer :version,      null: false, default: 1
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20140416202801_create_color_scheme_colors.rb b/db/migrate/20140416202801_create_color_scheme_colors.rb
new file mode 100644
index 000000000..bed6d5fac
--- /dev/null
+++ b/db/migrate/20140416202801_create_color_scheme_colors.rb
@@ -0,0 +1,14 @@
+class CreateColorSchemeColors < ActiveRecord::Migration
+  def change
+    create_table :color_scheme_colors do |t|
+      t.string  :name,            null: false
+      t.string  :hex,             null: false
+      t.integer :opacity,         null: false, default: 100
+      t.integer :color_scheme_id, null: false
+
+      t.timestamps
+    end
+
+    add_index :color_scheme_colors, [:color_scheme_id]
+  end
+end
diff --git a/spec/controllers/admin/color_schemes_controller_spec.rb b/spec/controllers/admin/color_schemes_controller_spec.rb
new file mode 100644
index 000000000..70f2b42e4
--- /dev/null
+++ b/spec/controllers/admin/color_schemes_controller_spec.rb
@@ -0,0 +1,72 @@
+require 'spec_helper'
+
+describe Admin::ColorSchemesController do
+  it "is a subclass of AdminController" do
+    (described_class < Admin::AdminController).should be_true
+  end
+
+  context "while logged in as an admin" do
+    let!(:user) { log_in(:admin) }
+    let(:valid_params) { { color_scheme: {
+        name: 'Such Design',
+        enabled: true,
+        colors: [
+          {name: '$primary_background_color', hex: 'FFBB00', opacity: '100'},
+          {name: '$secondary_background_color', hex: '888888', opacity: '70'}
+        ]
+      }
+    } }
+
+    describe "index" do
+      it "returns success" do
+        xhr :get, :index
+        response.should be_success
+      end
+
+      it "returns JSON" do
+        Fabricate(:color_scheme)
+        xhr :get, :index
+        ::JSON.parse(response.body).should be_present
+      end
+    end
+
+    describe "create" do
+      it "returns success" do
+        xhr :post, :create, valid_params
+        response.should be_success
+      end
+
+      it "returns JSON" do
+        xhr :post, :create, valid_params
+        ::JSON.parse(response.body)['id'].should be_present
+      end
+    end
+
+    describe "update" do
+      let(:existing) { Fabricate(:color_scheme) }
+
+      it "returns success" do
+        ColorSchemeRevisor.expects(:revise).returns(existing)
+        xhr :put, :update, valid_params.merge(id: existing.id)
+        response.should be_success
+      end
+
+      it "returns JSON" do
+        ColorSchemeRevisor.expects(:revise).returns(existing)
+        xhr :put, :update, valid_params.merge(id: existing.id)
+        ::JSON.parse(response.body)['id'].should be_present
+      end
+    end
+
+    describe "destroy" do
+      let!(:existing) { Fabricate(:color_scheme) }
+
+      it "returns success" do
+        expect {
+          xhr :delete, :destroy, id: existing.id
+        }.to change { ColorScheme.count }.by(-1)
+        response.should be_success
+      end
+    end
+  end
+end
diff --git a/spec/fabricators/color_scheme_color_fabricator.rb b/spec/fabricators/color_scheme_color_fabricator.rb
new file mode 100644
index 000000000..2bfe62b93
--- /dev/null
+++ b/spec/fabricators/color_scheme_color_fabricator.rb
@@ -0,0 +1,6 @@
+Fabricator(:color_scheme_color) do
+  color_scheme
+  name { sequence(:name) {|i| "$color_#{i}" } }
+  hex "333333"
+  opacity 100
+end
diff --git a/spec/fabricators/color_scheme_fabricator.rb b/spec/fabricators/color_scheme_fabricator.rb
new file mode 100644
index 000000000..6d4725eb1
--- /dev/null
+++ b/spec/fabricators/color_scheme_fabricator.rb
@@ -0,0 +1,5 @@
+Fabricator(:color_scheme) do
+  name { sequence(:name) {|i| "Palette #{i}" } }
+  enabled false
+  color_scheme_colors(count: 2) { |attrs, i| Fabricate.build(:color_scheme_color, color_scheme: nil) }
+end
\ No newline at end of file
diff --git a/spec/models/color_scheme_spec.rb b/spec/models/color_scheme_spec.rb
new file mode 100644
index 000000000..88e59324e
--- /dev/null
+++ b/spec/models/color_scheme_spec.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+
+describe ColorScheme do
+
+  let(:valid_params) { {name: "Best Colors Evar", enabled: true, colors: valid_colors} }
+  let(:valid_colors) { [
+    {name: '$primary_background_color', hex: 'FFBB00', opacity: '100'},
+    {name: '$secondary_background_color', hex: '888888', opacity: '70'}
+  ]}
+
+  describe "new" do
+    it "can take colors" do
+      c = described_class.new(valid_params)
+      c.colors.should have(valid_colors.size).colors
+      c.colors.first.should be_a(ColorSchemeColor)
+      expect {
+        c.save.should == true
+      }.to change { ColorSchemeColor.count }.by(valid_colors.size)
+    end
+  end
+
+  describe "destroy" do
+    it "also destroys old versions" do
+      c1 = described_class.create(valid_params.merge(version: 2))
+      c2 = described_class.create(valid_params.merge(versioned_id: c1.id, version: 1))
+      other = described_class.create(valid_params)
+      expect {
+        c1.destroy
+      }.to change { described_class.count }.by(-2)
+    end
+  end
+
+  describe "#enabled" do
+    it "returns the base color scheme when there is no enabled record" do
+      described_class.enabled.id.should == 1
+    end
+
+    it "returns the enabled color scheme" do
+      c = described_class.create(valid_params.merge(enabled: true))
+      described_class.enabled.id.should == c.id
+    end
+  end
+end
diff --git a/spec/services/color_scheme_revisor_spec.rb b/spec/services/color_scheme_revisor_spec.rb
new file mode 100644
index 000000000..66d97f8be
--- /dev/null
+++ b/spec/services/color_scheme_revisor_spec.rb
@@ -0,0 +1,116 @@
+require 'spec_helper'
+
+describe ColorSchemeRevisor do
+
+  let(:color)        { Fabricate.build(:color_scheme_color, hex: 'FFFFFF', color_scheme: nil) }
+  let(:color_scheme) { Fabricate(:color_scheme, enabled: false, created_at: 1.day.ago, updated_at: 1.day.ago, color_scheme_colors: [color]) }
+  let(:valid_params) { { name: color_scheme.name, enabled: color_scheme.enabled, colors: nil } }
+
+  describe "revise" do
+    it "does nothing if there are no changes" do
+      expect {
+        described_class.revise(color_scheme, valid_params.merge(colors: nil))
+      }.to_not change { color_scheme.reload.updated_at }
+    end
+
+    it "can change the name" do
+      described_class.revise(color_scheme, valid_params.merge(name: "Changed Name"))
+      color_scheme.reload.name.should == "Changed Name"
+    end
+
+    it "can enable and disable" do
+      described_class.revise(color_scheme, valid_params.merge(enabled: true))
+      color_scheme.reload.should be_enabled
+      described_class.revise(color_scheme, valid_params.merge(enabled: false))
+      color_scheme.reload.should_not be_enabled
+    end
+
+    it "can change colors" do
+      described_class.revise(color_scheme, valid_params.merge(colors: [
+        {name: color.name, hex: 'BEEF99', opacity: 99}
+      ]))
+      color_scheme.reload
+      color_scheme.colors.size.should == 1
+      color_scheme.colors.first.hex.should == 'BEEF99'
+      color_scheme.colors.first.opacity.should == 99
+    end
+
+    it "disables other color scheme before enabling" do
+      prev_enabled = Fabricate(:color_scheme, enabled: true)
+      described_class.revise(color_scheme, valid_params.merge(enabled: true))
+      prev_enabled.reload.enabled.should == false
+      color_scheme.reload.enabled.should == true
+    end
+
+    describe "versions" do
+      it "doesn't create a new version if colors is not given" do
+        expect {
+          described_class.revise(color_scheme, valid_params.merge(name: "Changed Name"))
+        }.to_not change { color_scheme.reload.version }
+      end
+
+      it "creates a new version if colors have changed" do
+        old_hex, old_opacity = color.hex, color.opacity
+        expect {
+          described_class.revise(color_scheme, valid_params.merge(colors: [
+            {name: color.name, hex: 'BEEF99', opacity: 99}
+          ]))
+        }.to change { color_scheme.reload.version }.by(1)
+        old_version = ColorScheme.where(versioned_id: color_scheme.id, version: color_scheme.version - 1).first
+        old_version.should_not be_nil
+        old_version.colors.count.should == color_scheme.colors.count
+        old_version.colors_by_name[color.name].hex.should == old_hex
+        old_version.colors_by_name[color.name].opacity.should == old_opacity
+        color_scheme.colors_by_name[color.name].hex.should == 'BEEF99'
+        color_scheme.colors_by_name[color.name].opacity.should == 99
+      end
+
+      it "doesn't create a new version if colors have not changed" do
+        expect {
+          described_class.revise(color_scheme, valid_params.merge(colors: [
+            {name: color.name, hex: color.hex, opacity: color.opacity}
+          ]))
+        }.to_not change { color_scheme.reload.version }
+      end
+    end
+  end
+
+  describe "revert" do
+    context "when there are no previous versions" do
+      it "does nothing" do
+        expect {
+          described_class.revert(color_scheme).should == color_scheme
+        }.to_not change { color_scheme.reload.version }
+      end
+    end
+
+    context 'when there are previous versions' do
+      let(:new_color_params) { {name: color.name, hex: 'BEEF99', opacity: 99} }
+
+      before do
+        @prev_hex, @prev_opacity = color.hex, color.opacity
+        described_class.revise(color_scheme, valid_params.merge(colors: [ new_color_params ]))
+      end
+
+      it "reverts the colors to the previous version" do
+        color_scheme.colors_by_name[new_color_params[:name]].hex.should == new_color_params[:hex]
+        expect {
+          described_class.revert(color_scheme)
+        }.to change { color_scheme.reload.version }.by(-1)
+        color_scheme.colors.size.should == 1
+        color_scheme.colors.first.hex.should == @prev_hex
+        color_scheme.colors.first.opacity.should == @prev_opacity
+        color_scheme.colors_by_name[new_color_params[:name]].hex.should == @prev_hex
+        color_scheme.colors_by_name[new_color_params[:name]].opacity.should == @prev_opacity
+      end
+
+      it "destroys the old version's record" do
+        expect {
+          described_class.revert(color_scheme)
+        }.to change { ColorScheme.count }.by(-1)
+        color_scheme.reload.previous_version.should be_nil
+      end
+    end
+  end
+
+end