diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..7d512db
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,61 @@
+name: Pages
+
+on:
+ pull_request:
+ push:
+ branches:
+ - master
+
+permissions:
+ contents: read
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests
+ run: npm test
+
+ - name: Build site
+ run: npm run build
+
+ - name: Upload Pages artifact
+ if: github.event_name == 'push'
+ uses: actions/upload-pages-artifact@v5
+ with:
+ path: ./dist
+
+ deploy:
+ if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+ needs: verify
+ runs-on: ubuntu-latest
+
+ permissions:
+ pages: write
+ id-token: write
+
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/.gitignore b/.gitignore
index cfd9b9a..8751270 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,10 @@
*.swp
node_modules
+dist
+coverage
+playwright-report
+test-results
+TODO.md
js/compiled.js
css/_compiled_main.css
css/merged.css
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index 2d537c5..0000000
--- a/.gitmodules
+++ /dev/null
@@ -1,3 +0,0 @@
-[submodule "lib/google-closure-library"]
- path = lib/google-closure-library
- url = https://github.com/ciembor/google-closure-library
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..5bf4400
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+24.15.0
diff --git a/.project b/.project
deleted file mode 100644
index 80af9b7..0000000
--- a/.project
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
- 4bit
-
-
-
-
-
-
- com.aptana.projects.webnature
-
-
diff --git a/4bit-terminal-color-scheme-designer.webp b/4bit-terminal-color-scheme-designer.webp
new file mode 100644
index 0000000..fc63bc0
Binary files /dev/null and b/4bit-terminal-color-scheme-designer.webp differ
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f639757
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,237 @@
+# Changelog
+
+This changelog summarizes the full project history reconstructed from the repository commits since the project started on August 20, 2012.
+
+Early history is grouped by day to keep the file readable while still covering the full timeline.
+
+## 2026
+
+### 2026-05-15
+
+- Reorganized the xterm.js terminal preview modules around terminal-view naming to avoid confusion with the xterm export format.
+- Split terminal preview command builders into a dedicated commands area.
+- Added a live `usability` command that reports WCAG-based text contrast for the current terminal palette.
+- Updated terminal help output with grouped tools and example commands, including colored command names and short descriptions.
+
+### 2026-05-14
+
+- Migrated the terminal preview window to xterm.js while preserving the classic 4bit terminal proportions and font behavior.
+- Added an interactive terminal preview prompt with commands for color matrix, `git diff`, `git status`, `ls`, `ls -al`, `clear`, and help output.
+- Kept terminal preview content stable during slider changes by updating the theme without restoring the boot transcript.
+- Fixed terminal view dimensions while the page and font are loading.
+
+### 2026-05-13
+
+- Added export support for Windows Terminal, Termite, ConEmu, KiTTY, and macOS Terminal.
+- Updated GNOME Terminal export support for the newer format without keeping the old fallback.
+- Stabilized the ConEmu snapshot date used in generated output.
+
+### 2026-05-12
+
+- Restyled About page feature cards to resemble terminal previews.
+
+### 2026-05-11
+
+- Added a dedicated SEO-focused About page for 4bit with project history, supported terminal links, source references, and an explanation of the color-generation model.
+- Reworked the page header navigation so the About page links back to the editor while the editor keeps the download workflow.
+- Shared the page shell, header, footer, button, theme, asset, and store code between the editor and About page.
+- Reorganized presentation code into `about-page`, `editor-page`, and `shared` areas to reduce coupling between page-specific UI and shared layout.
+- Decoupled browser URL synchronization from Pinia by passing the scheme store into the infrastructure adapter.
+
+### 2026-05-10
+
+- Added compact `s` query-string serialization for complete scheme settings while keeping backward compatibility with legacy shared URLs.
+- Added mintty drag-and-drop URL payload support so generated palettes can be dropped into mintty from shared links.
+- Updated social sharing and browser URL synchronization to use the compressed scheme settings and mintty drag payload.
+- Extended unit and browser smoke coverage for compressed settings, legacy URL hydration, sharing URLs, and mintty drag serialization.
+
+### 2026-04-22
+
+- Completed the Vue migration of the remaining UI pieces.
+- Refactored `Advanced` controls to Vue components while keeping the legacy jQuery color wheel wrapped inside Vue.
+- Ported newer legacy `master` changes into the Vue app, including exporter updates and newer defaults.
+- Removed leftover legacy entrypoints and duplicated assets that were no longer used by the Vite app.
+- Unified slider wrappers and added proper jQuery UI cleanup on unmount.
+- Replaced runtime `
+
+
-
-
-
-
-
-
-
- 4 b i t Terminal Color Scheme Designer
-
-
-
-
-
-
-
-
-
- Global Properties
- Hue:
-
- Saturation:
-
-
-
- Lightness
- Color:
-
- Black:
-
- White:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
diff --git a/js/main.js b/js/main.js
deleted file mode 100755
index 45f451d..0000000
--- a/js/main.js
+++ /dev/null
@@ -1,1096 +0,0 @@
-/**
- * This is the main script of Terminal Color Scheme Designer
- * author: Maciej Ciemborowicz
- */
-
-// social media ///////////////////////////////////////////////////////////////////////////////////
-
-(function() {
-
- // twitter button
- !function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0];if(!d.getElementById(id)){js=d.createElement(s);js.id=id;js.src="//platform.twitter.com/widgets.js";fjs.parentNode.insertBefore(js,fjs);}}(document,"script","twitter-wjs");
-
-})();
-
-// jquery ui vertial radio ////////////////////////////////////////////////////////////////////////
-
-(function( $ ){
- //plugin buttonset vertical
- $.fn.buttonsetv = function() {
- $(':radio, :checkbox', this).wrap('
');
- $(this).buttonset();
- $('label:first', this).removeClass('ui-corner-left').addClass('ui-corner-top');
- $('label:last', this).removeClass('ui-corner-right').addClass('ui-corner-bottom');
- mw = 0; // max witdh
- $('label', this).each(function(index){
- w = $(this).width();
- if (w > mw) mw = w;
- })
- $('label', this).each(function(index){
- $(this).width(mw);
- })
- };
-})( jQuery );
-
-// Backbone app ///////////////////////////////////////////////////////////////////////////////////
-
-_4bit = function() {
-
- goog.require('goog.color');
-
- /**
- * Creates HSL color objects
- *
- * @param {Number} h Hue between 0 and 360
- * @param {Number} s Saturation between 0 and 1
- * @param {Number} l Lightness between 0 and 1
- *
- * @return {Object} HSL color
- */
- function HSL(h, s, l) {
- var color = [h, s, l];
- var dye = [0, 0, 0, 0]; // hsla tint
-
- getHue = function() {
- return color[0];
- }
-
- getSaturation = function() {
- return color[1];
- }
-
- getLightness = function() {
- return color[2];
- }
-
- setHue = function(h) {
- color[0] = h;
- }
-
- setSaturation = function(s) {
- color[1] = s;
- }
-
- setLightness = function(l) {
- color[2] = l;
- }
-
- setDye = function(hsla) {
- dye = hsla;
- }
-
- setHsl = function(h, s, l) {
- color = [h, s, l];
- }
-
- stringify = function() {
- var blended = goog.color.hslArrayToRgb(color);
- var blender = goog.color.hslToRgb(dye[0], dye[1], dye[2]);
- var factor = dye[3];
- var rgb = goog.color.blend(blender, blended, factor);
- return goog.color.rgbArrayToHex(rgb);
- }
-
- toRgb = function() {
- return goog.color.hslArrayToRgb(color);
- }
-
- return {
- getHue: getHue,
- getSaturation: getSaturation,
- getLightness: getLightness,
- setHue: setHue,
- setSaturation: setSaturation,
- setLightness: setLightness,
- setDye: setDye,
- setHsl: setHsl,
- toString: stringify,
- toRgb: toRgb
- }
- }
-
- var COLOR_NAMES = [
- 'black',
- 'bright_black',
- 'red',
- 'bright_red',
- 'green',
- 'bright_green',
- 'yellow',
- 'bright_yellow',
- 'blue',
- 'bright_blue',
- 'magenta',
- 'bright_magenta',
- 'cyan',
- 'bright_cyan',
- 'white',
- 'bright_white'
- ]
-
- var Scheme = Backbone.Model.extend({
-
- defaults: {
- hue: 0,
- saturation: 0.3,
- normal_lightness: 0.6,
- bright_lightness: 0.8,
- black: [HSL(0, 0, 0), HSL(0, 0, 0.15)],
- white: [HSL(0, 0, 0.85), HSL(0, 0, 1)],
- background: HSL(0, 0, 0),
- foreground: HSL(0, 0, 1)
- },
-
- initialize: function() {
- var that = this
- var degrees = [0, 60, 120, 180, 240, 300];
- var normal_array = _.map(degrees, function(degree) {
- return HSL(that.get('hue') + degree, that.get('saturation'), that.get('normal_lightness'));
- });
- var bright_array = _.map(degrees, function(degree) {
- return HSL(that.get('hue') + degree, that.get('saturation'), that.get('bright_lightness'));
- });
-
- this.set({
- bright: bright_array,
- normal: normal_array
- });
-
- this.set({
- colors: {
- background: this.get('background'),
- foreground: this.get('foreground'),
- black: this.get('black')[0],
- bright_black: this.get('black')[1],
- red: this.get('normal')[0],
- bright_red: this.get('bright')[0],
- green: this.get('normal')[2],
- bright_green: this.get('bright')[2],
- yellow: this.get('normal')[1],
- bright_yellow: this.get('bright')[1],
- blue: this.get('normal')[4],
- bright_blue: this.get('bright')[4],
- magenta: this.get('normal')[5],
- bright_magenta: this.get('bright')[5],
- cyan: this.get('normal')[3],
- bright_cyan: this.get('bright')[3],
- white: this.get('white')[0],
- bright_white: this.get('white')[1]
- }
- });
- },
-
- setHue: function(hue) {
- this.set('hue', this.get('hue') + hue)
- _.each([this.get('bright'), this.get('normal')], function(colors) {
- _.each(colors, function(color) {
- color.setHue(hue + color.getHue());
- });
- });
- this.trigger('change');
- },
-
- setSaturation: function(saturation) {
- this.set('saturation', saturation)
- _.each([this.get('bright'), this.get('normal')], function(colors) {
- _.each(colors, function(color) {
- color.setSaturation(saturation);
- });
- });
- this.trigger('change');
- },
-
- setLightness: function(type, lightness) {
-
- switch(type) {
- case 'normal':
- this.set('normal_lightness', lightness)
- _.each(this.get('normal'), function(color) {
- color.setLightness(lightness);
- });
- break;
- case 'bright':
- this.set('bright_lightness', lightness)
- _.each(this.get('bright'), function(color) {
- color.setLightness(lightness);
- });
- break;
- default:
- this.get('colors')[type].setLightness(lightness);
- }
-
- this.trigger('change');
- },
-
- dye: function(h, s, l, a, type) {
- var colors = this.get('colors');
- var achromatic = [
- colors.black,
- colors.bright_black,
- colors.white,
- colors.bright_white
- ];
- var colors_array = [];
-
- if ('achromatic' === type) {
- colors_array.push(achromatic);
- } else if ('color' === type) {
- colors_array.push(this.get('bright'));
- colors_array.push(this.get('normal'));
- } else {
- colors_array.push(achromatic);
- colors_array.push(this.get('bright'));
- colors_array.push(this.get('normal'));
- }
-
- this.set('dye', [h, s, l, a]);
-
- _.each(colors_array, function(colors) {
- _.each(colors, function(color) {
- color.setDye([h, s, l, a]);
- });
- });
-
- this.trigger('change');
- },
-
- setBackground: function(h, s, l, option) {
- var background = this.get('background');
-
- if ('custom' === option) {
- background.setHsl(h, s, l);
- this.get('colors')['background'] = background;
- } else {
- this.get('colors')['background'] = this.get('colors')[option];
- }
-
- this.trigger('change');
- },
-
- setForeground: function(h, s, l, option) {
- var foreground = this.get('foreground');
-
- if ('custom' === option) {
- foreground.setHsl(h, s, l);
- this.get('colors')['foreground'] = foreground;
- } else {
- this.get('colors')['foreground'] = this.get('colors')[option];
- }
-
- this.trigger('change');
- }
-
- });
-
- var scheme = new Scheme();
-
- var SchemeView = Backbone.View.extend({
-
- el: $('#display'),
-
- model: scheme,
-
- initialize: function() {
- _.bindAll(this, 'render');
- this.render();
-
- $("#advanced").tabs();
- },
-
- render: function() {
- var string = '';
- var color_names = ['foreground', 'bright_foreground'];
- var bg_names = ['background'];
- var columns_th = [' ', ' ', '40m', '41m', '42m', '43m', '44m', '45m', '46m', '47m'];
- var rows_th =['m','1m','30m','1;30m','31m','1;31m','32m','1;32m','33m','1;33m','34m','1;34m','35m','1;35m','36m','1;36m','37m','1;37m'];
- var row_index = 0;
-
- string += 'Welcome to fish, the friendly interactive shell
';
- string += 'Type help for instructions on how to use fish
'
- string += 'ciembor @browser ~ > ./colors.sh
'
- string += ' ';
-
- /* table with colors */
- string += '';
-
- string += '';
- _.each(columns_th, function(th) {
- string += '' + th + ' ';
- });
- string += ' ';
-
- _.each(COLOR_NAMES, function(name) {
- if (0 !== name.indexOf('bright_')) {
- bg_names.push(name);
- }
- color_names.push(name);
- });
-
- _.each(color_names, function(name) {
- string += '';
- string += '' + rows_th[row_index] + ' ';
- row_index += 1;
-
- _.each(bg_names, function(bg_name) {
- string += 'gYw ';
- })
-
- string += ' ';
- })
- string += '
';
-
- string += ' ';
- string += 'ciembor @browser ~ >
';
-
- $(this.el).html(string);
- }
-
- });
-
- var SchemeCSSView = Backbone.View.extend({
-
- model: scheme,
-
- initialize: function() {
- _.bindAll(this, 'render');
- this.model.bind('change', _.bind(this.render, this));
- this.render();
- },
-
- render: function() {
- var that = this;
- $('#display').css('color', that.model.get('colors')['foreground']);
- $('#display').css('background-color', that.model.get('colors')['background']);
- _.each(COLOR_NAMES, function(name) {
- $('.' + name).css('color', that.model.get('colors')[name]);
- $('.bg-' + name).css('background-color', that.model.get('colors')[name]);
- });
- }
-
- });
-
- var SchemeGuakeView = Backbone.View.extend({
-
- model: scheme,
-
- initialize: function() {
- _.bindAll(this, 'render');
- var that = this;
- $('#guake-button').on('click', function(event) {
- var blob = that.render();
- var blobURL = URL.createObjectURL(blob);
- var link = $(event.target);
-
- link.attr('href', blobURL);
- link.attr('download', 'set_colors.sh');
- });
- },
-
- render: function() {
- var that = this;
- var palette = [];
- var colors = that.model.get("colors");
- var blob = null;
- var file = null;
-
- // Duplicate: #ab1224 -> #abab12122424, which is the expected format
- function gnomeColor(color) {
- return color.toString().replace(/#(.{2})(.{2})(.{2})/, '#$1$1$2$2$3$3');
- }
-
- _.each(COLOR_NAMES, function(name) {
- palette.push( gnomeColor(colors[name]) )
- });
-
- out = '#!/bin/bash \n\n';
- out += '# Save this script into set_colors.sh, make this file executable and run it: \n';
- out += '# \n';
- out += '# $ chmod +x set_colors.sh \n';
- out += '# $ ./set_colors.sh \n';
- out += '# \n';
- out += '# Alternatively copy lines below directly into your shell. \n\n';
-
- out += "gconftool-2 -s -t string /apps/guake/style/background/color '" + gnomeColor(colors["background"]) + "'" +'\n';
- out += "gconftool-2 -s -t string /apps/guake/style/font/color '" + gnomeColor(colors["foreground"]) + "'" + '\n';
- out += "gconftool-2 -s -t string /apps/guake/style/font/palette '" + palette.join(":") + "'" + '\n';
-
- return new Blob([out], { type: 'text/text' });
- }
-
- });
-
- var SchemeGnomeTerminalView = Backbone.View.extend({
-
- model: scheme,
-
- initialize: function() {
- _.bindAll(this, 'render');
- var that = this;
- $('#gnome-terminal-button').on('click', function(event) {
- var blob = that.render();
- var blobURL = URL.createObjectURL(blob);
- var link = $(event.target);
-
- link.attr('href', blobURL);
- link.attr('download', 'set_colors.sh');
- });
- },
-
- render: function() {
- var that = this;
- var palette = [];
- var colors = that.model.get("colors");
-
- // Duplicate: #ab1224 -> #abab12122424, which is the expected format
- function gnomeColor(color) {
- return color.toString().replace(/#(.{2})(.{2})(.{2})/, '#$1$1$2$2$3$3');
- }
-
- _.each(COLOR_NAMES, function(name) {
- if (0 !== name.indexOf('bright_')) {
- palette.push( gnomeColor(colors[name]) );
- }
- });
-
- _.each(COLOR_NAMES, function(name) {
- if (0 === name.indexOf('bright_')) {
- palette.push( gnomeColor(colors[name]) );
- }
- });
-
- out = '#!/bin/bash \n\n';
- out += '# Save this script into set_colors.sh, make this file executable and run it: \n';
- out += '# \n';
- out += '# $ chmod +x set_colors.sh \n';
- out += '# $ ./set_colors.sh \n';
- out += '# \n';
- out += '# Alternatively copy lines below directly into your shell. \n\n';
-
- out += "gconftool-2 --set /apps/gnome-terminal/profiles/Default/use_theme_background --type bool false \n";
- out += "gconftool-2 --set /apps/gnome-terminal/profiles/Default/use_theme_colors --type bool false \n";
- out += "gconftool-2 -s -t string /apps/gnome-terminal/profiles/Default/background_color '" + gnomeColor(colors["background"]) + "'" +'\n';
- out += "gconftool-2 -s -t string /apps/gnome-terminal/profiles/Default/foreground_color '" + gnomeColor(colors["foreground"]) + "'" + '\n';
- out += "gconftool-2 -s -t string /apps/gnome-terminal/profiles/Default/palette '" + palette.join(":") + "'" + '\n';
-
- return new Blob([out], { type: 'text/text' });
- }
-
- });
-
- var SchemeKonsoleView = Backbone.View.extend({
-
- model: scheme,
-
- initialize: function() {
- _.bindAll(this, 'render');
- var that = this;
- $('#konsole-button').on('click', function(event) {
- var blob = that.render();
- var blobURL = URL.createObjectURL(blob);
- var link = $(event.target);
-
- link.attr('href', blobURL);
- link.attr('download', '4bit.colorscheme');
- });
- },
-
- colorRgb: function(context, color) {
- var rgbArray = context.model.get("colors")[color].toRgb();
- return rgbArray[0] + ',' + rgbArray[1] + ',' + rgbArray[2];
- },
-
- render: function() {
- var that = this;
- var out = '';
- var counter = 0;
- var tpf = "Transparency=false" + '\n' + '\n';
- var name = '4bit-' + that.model.get("colors")["foreground"] + "-on-" + that.model.get("colors")["background"];
- name = name.replace(/#/g,'');
-
- out += '# --- ~/.kde/share/apps/konsole/NAME.colorscheme -------------------------------\n';
- out += '# ------------------------------------------------------------------------------\n';
- out += '# --- generated with 4bit Terminal Color Scheme Designer -----------------------\n';
- out += '# ------------------------------------------------------------------------------\n';
- out += '# --- http://ciembor.github.io/4bit --------------------------------------------\n';
- out += '# ------------------------------------------------------------------------------\n\n';
-
- out += '# --- special colors ---\n\n';
- out += '[Background]\n';
- out += 'Color=' + that.colorRgb(that, "background") + '\n';
- out += tpf;
- out += '[BackgroundIntense]\n';
- out += 'color=' + that.colorRgb(that, "background") + '\n';
- out += tpf;
- out += '[Foreground]\n';
- out += 'Color=' + that.colorRgb(that, "foreground") + '\n';
- out += tpf;
- out += '[ForegroundIntense]\n';
- out += 'Color=' + that.colorRgb(that, "foreground") + '\n';
- out += 'Bold=true\n';
- out += tpf;
-
- out += '# --- standard colors ---\n\n';
- _.each(COLOR_NAMES, function(name) {
- var number = counter / 2;
-
- if (0 === name.indexOf('bright_')) {
- number += 7.5;
- }
- if (number > 7) {
- colorsuffix = number % 8 + "Intense";
- } else {
- colorsuffix = number % 8;
- }
- out += '[Color' + colorsuffix + ']\n';
- out += 'Color=' + that.colorRgb(that, name) + '\n';
- out += tpf;
- counter++;
- });
-
- out += '# --- general options ---\n\n';
- out += '[General]\nDescription=' + name + '\nOpacity=1\n';
-
- return new Blob([out], { type: 'text/text' });
- }
-
- });
-
- var SchemeITerm2View = Backbone.View.extend({
-
- model: scheme,
-
- initialize: function() {
- _.bindAll(this, 'render');
- var that = this;
- $('#iterm2-button').on('click', function(event) {
- var blob = that.render();
- var blobURL = URL.createObjectURL(blob);
- var link = $(event.target);
-
- link.attr('href', blobURL);
- link.attr('download', '4bit.itermcolors');
- });
- },
-
- colorRgb: function(context, color) {
- var rgbArray = context.model.get("colors")[color].toRgb();
- return rgbArray[0] + ',' + rgbArray[1] + ',' + rgbArray[2];
- },
-
- colorKeyDict: function(context, color, name) {
- var rgbArray = context.model.get("colors")[color].toRgb();
- var out = '';
- out += ' '+name+' Color \n';
- out += ' \n';
- out += ' Blue Component \n';
- out += ' '+rgbArray[2]/255+' \n';
- out += ' Green Component \n';
- out += ' '+rgbArray[1]/255+' \n';
- out += ' Red Component \n';
- out += ' '+rgbArray[0]/255+' \n';
- out += ' \n';
- return out;
- },
-
- render: function() {
- var that = this;
- var out = '';
- var counter = 0;
- var name = '4bit-' + that.model.get("colors")["foreground"] + "-on-" + that.model.get("colors")["background"];
- name = name.replace(/#/g,'');
-
- out += '\n';
- out += '\n';
- out += '\n\n\n';
-
- out += '\n';
- out += '\n';
-
- out += '\n';
- out += that.colorKeyDict(that, "background", "Background");
- out += that.colorKeyDict(that, "foreground", "Foreground");
- out += that.colorKeyDict(that, "foreground", "Cursor");
- out += that.colorKeyDict(that, "background", "Cursor Text");
-
- out += '\n';
-
- _.each(COLOR_NAMES, function(name) {
- var number = counter / 2;
-
- if (0 === name.indexOf('bright_')) {
- number += 7.5;
- }
-
- out += ' \n';
- out += that.colorKeyDict(that, name, "Ansi "+number);
-
- counter += 1;
- });
-
- out += ' \n';
- out += ' \n';
- out += '\n';
-
- return new Blob([out], { type: 'text/text' });
- }
-
- });
-
- var SchemeXresourcesView = Backbone.View.extend({
-
- model: scheme,
-
- initialize: function() {
- _.bindAll(this, 'render');
- var that = this;
- $('#xresources-button').on('click', function(event) {
- var blob = that.render();
- var blobURL = URL.createObjectURL(blob);
- var link = $(event.target);
-
- link.attr('href', blobURL);
- link.attr('download', '.Xresources');
- });
- },
-
- render: function() {
- var that = this;
- var xresources = '';
- var counter = 0;
-
- xresources += '! --- ~/.Xresources ------------------------------------------------------------\n';
- xresources += '! ------------------------------------------------------------------------------\n';
- xresources += '! --- generated with 4bit Terminal Color Scheme Designer -----------------------\n';
- xresources += '! ------------------------------------------------------------------------------\n';
- xresources += '! --- http://ciembor.github.io/4bit --------------------------------------------\n';
- xresources += '! ------------------------------------------------------------------------------\n\n';
-
- xresources += '! --- special colors ---\n\n';
- xresources += '*background: ' + that.model.get('colors')['background'] + '\n';
- xresources += '*foreground: ' + that.model.get('colors')['foreground'] + '\n\n';
-
- xresources += '! --- standard colors ---\n\n';
- _.each(COLOR_NAMES, function(name) {
- var number = counter / 2;
-
- if (0 === name.indexOf('bright_')) {
- number += 7.5;
- }
-
- xresources += '! ' + name + '\n';
- xresources += '*color' + number + ': ' + that.model.get('colors')[name] + '\n\n';
- counter += 1;
- });
-
- xresources += '\n! ------------------------------------------------------------------------------\n';
- xresources += '! --- end of terminal colors section -------------------------------------------\n';
- xresources += '! ------------------------------------------------------------------------------\n\n';
-
- return new Blob([xresources], { type: 'text/text' });
- }
-
- });
-
- var SchemeXfceTerminalView = Backbone.View.extend({
-
- model: scheme,
-
- initialize: function() {
- _.bindAll(this, 'render');
- var that = this;
- $('#xfce-terminal-button').on('click', function(event) {
- var blob = that.render();
- var blobURL = URL.createObjectURL(blob);
- var link = $(event.target);
-
- link.attr('href', blobURL);
- link.attr('download', 'terminalrc');
- });
- },
-
- render: function() {
- var that = this;
- var terminalrc = '[Configuration]\n';
- var counter = 1;
-
- // special colors
- terminalrc += 'ColorBackground=' + that.model.get('colors')['background'] + '\n';
- terminalrc += 'ColorForeground=' + that.model.get('colors')['foreground'] + '\n';
- terminalrc += 'ColorCursor=' + that.model.get('colors')['foreground'] + '\n';
-
- // standard colors
- _.each(COLOR_NAMES, function(name) {
- var number = counter / 2 + 0.5;
-
- if (0 === name.indexOf('bright_')) {
- number += 7.5;
- }
-
- terminalrc += 'ColorPalette' + number + '=' + that.model.get('colors')[name] + '\n';
- counter += 1;
- });
-
- return new Blob([terminalrc], { type: 'text/text' });
- }
-
- });
-
- var SchemeMinttyView = Backbone.View.extend({
-
- model: scheme,
-
- initialize: function() {
- _.bindAll(this, 'render');
- var that = this;
- $('#mintty-button').on('click', function(event) {
- var blob = that.render();
- var blobURL = URL.createObjectURL(blob);
- var link = $(event.target);
-
- link.attr('href', blobURL);
- link.attr('download', '4bit-mintty-color-scheme');
- });
- },
-
- colorRgb: function(context, color) {
- var rgbArray = context.model.get("colors")[color].toRgb();
- return rgbArray[0] + ',' + rgbArray[1] + ',' + rgbArray[2];
- },
-
- render: function() {
- function MinttyName(name) {
- if (0 === name.indexOf('bright_')) {
- name = name.substring('bright_'.length);
- return 'Bold' + name.charAt(0).toUpperCase() + name.slice(1);
- } else {
- return name.charAt(0).toUpperCase() + name.slice(1);
- }
- }
-
- var that = this;
- var out = '';
-
- out += 'BackgroundColour=' + that.colorRgb(that, "background") + '\n';
- out += 'ForegroundColour=' + that.colorRgb(that, "foreground") + '\n';
- out += 'CursorColour=' + that.colorRgb(that, "foreground") + '\n';
-
- _.each(COLOR_NAMES, function(name) {
- out += MinttyName(name) + '=' + that.colorRgb(that, name) + '\n';
- });
-
- return new Blob([out], { type: 'text/text' });
- }
-
- });
-
- var SchemePuttyView = Backbone.View.extend({
-
- model: scheme,
-
- initialize: function() {
- _.bindAll(this, 'render');
- var that = this;
- $('#putty-button').on('click', function(event) {
- var blob = that.render();
- var blobURL = URL.createObjectURL(blob);
- var link = $(event.target);
-
- link.attr('href', blobURL);
- link.attr('download', '4bit-putty-color-scheme.reg');
- });
- },
-
- colorRgb: function(context, color) {
- var rgbArray = context.model.get("colors")[color].toRgb();
- return rgbArray[0] + ',' + rgbArray[1] + ',' + rgbArray[2];
- },
-
- render: function() {
- var that = this;
- var out = '';
- var counter = 6;
- out += 'Windows Registry Editor Version 5.00 \n\n';
- out += '[HKEY_CURRENT_USER\\Software\\SimonTatham\\PuTTY\\Sessions\\Default%20Settings]\n';
-
- out += '"Colour0"="' + that.colorRgb(that, "foreground") + '"\n';
- out += '"Colour1"="' + that.colorRgb(that, "foreground") + '"\n';
- out += '"Colour2"="' + that.colorRgb(that, "background") + '"\n';
- out += '"Colour3"="' + that.colorRgb(that, "background") + '"\n';
- out += '"Colour4"="' + that.colorRgb(that, "background") + '"\n';
- out += '"Colour5"="' + that.colorRgb(that, "foreground") + '"\n';
-
- _.each(COLOR_NAMES, function(name) {
- out += '"Colour' + counter + '"="' + that.colorRgb(that, name) + '"\n';
- counter += 1;
- });
-
- return new Blob([out], { type: 'text/text' });
- }
-
- });
-
- var SchemeTerminatorView = Backbone.View.extend({
-
- model: scheme,
-
- initialize: function() {
- _.bindAll(this, 'render');
- var that = this;
- $('#terminator-button').on('click', function(event) {
- var blob = that.render();
- var blobURL = URL.createObjectURL(blob);
- var link = $(event.target);
-
- link.attr('href', blobURL);
- link.attr('download', 'config');
- });
- },
-
- render: function() {
- var that = this;
- var out = '';
- var palette_normal = [];
- var palette_bright = [];
- var colors = that.model.get('colors');
- var name = '4bit-' + (new Date()).getTime();
-
- _.each(COLOR_NAMES, function(name) {
- if (0 === name.indexOf('bright_')) {
- palette_bright.push(colors[name]);
- palette_normal.push(colors[name.substr('bright_'.length)]);
- }
- });
-
- var palette = palette_normal.concat(palette_bright);
-
- out += '# Color scheme configuration for Terminator terminal emulator (http://gnometerminator.blogspot.com/p/introduction.html and https://launchpad.net/terminator)\n';
- out += '# \n';
- out += '# Copy the following lines within the [profiles] section of terminator configuration file at ~/.config/terminator/config\n\n';
-
- out += '[[' + name + ']]\n';
- out += ' use_theme_colors = False\n';
- out += ' background_color = "' + colors['background'] + '"\n';
- out += ' foreground_color = "' + colors['foreground'] + '"\n';
- out += ' palette = "' + palette.join(':') + '"' + '\n';
-
- return new Blob([out], { type: 'text/text' });
- }
- });
-
- var ControlsView = Backbone.View.extend({
-
- el: $('#controls'),
-
- model: scheme,
-
- initialize: function() {
- _.bindAll(this, 'render');
-
- var that = this;
-
- $("#hue-slider").slider({
- value: that.model.get('hue') + 30,
- min: 0,
- max: 60,
- step: 1,
- slide: function( event, ui ) {
- that.model.setHue((ui.value - 30) - that.model.get('hue'));
- }
- });
-
- $("#saturation-slider").slider({
- value: that.model.get('saturation') * 256,
- min: 0,
- max: 256,
- step: 1,
- slide: function( event, ui ) {
- that.model.setSaturation(ui.value / 256);
- }
- });
-
- $("#lightness-slider").slider({
- range: true,
- values: [that.model.get('normal_lightness') * 256, that.model.get('bright_lightness') * 256],
- min: 0,
- max: 256,
- step: 1,
- slide: function( event, ui ) {
- that.model.setLightness('normal', ui.values[0] / 256);
- that.model.setLightness('bright', ui.values[1] / 256);
- }
- });
-
- $("#black-slider").slider({
- range: true,
- values: [
- that.model.get('colors').black.getLightness() * 256,
- that.model.get('colors').bright_black.getLightness() * 256
- ],
- min: 0,
- max: 128,
- step: 1,
- slide: function( event, ui ) {
- that.model.setLightness('black', ui.values[0] / 256);
- that.model.setLightness('bright_black', ui.values[1] / 256);
- }
- });
-
- $("#white-slider").slider({
- range: true,
- values: [
- that.model.get('colors').white.getLightness() * 256,
- that.model.get('colors').bright_white.getLightness() * 256
- ],
- min: 128,
- max: 256,
- step: 1,
- slide: function( event, ui ) {
- that.model.setLightness('white', ui.values[0] / 256);
- that.model.setLightness('bright_white', ui.values[1] / 256);
- }
- });
-
- $('#dye-colorpicker').colorPicker({
- format: 'hsla',
- size: 90,
- colorChange: function(e, ui) {
- var pattern, _ref, h, s, l, a;
- var type = $('input[name=dye]:checked').val()
- pattern = /^hsla\((\d+),\s+(\d+(?:.\d+)?)%,\s+(\d+(?:.\d+)?)%,\s+(\d+(?:.\d+)?)\)$/;
- _ref = pattern.exec(ui.color), h = _ref[1], s = _ref[2] / 100, l = _ref[3] / 100, a = _ref[4];
-
- switch(type) {
- case 'none':
- that.model.dye(0, 0, 0, 0, 'all');
- break;
- case 'color':
- that.model.dye(0, 0, 0, 0, 'achromatic');
- that.model.dye(h, s, l, a, 'color');
- break;
- case 'achromatic':
- that.model.dye(0, 0, 0, 0, 'color');
- that.model.dye(h, s, l, a, 'achromatic');
- break;
- case 'all':
- that.model.dye(h, s, l, a, 'all');
- break;
- }
- }
- });
-
- $('#dye-colorpicker').colorPicker('setColor', 210, 50, 50, 0.2);
-
- $("input[name=dye]").change(function() {
- $('#dye-colorpicker').change();
- });
-
- $('#background-colorpicker').colorPicker({
- format: 'hsl',
- size: 90,
- colorChange: function(e, ui) {
- var pattern, _ref, h, s, l;
- var option = $('input[name=background]:checked').val()
- pattern = /^hsl\((\d+),\s+(\d+(?:.\d+)?)%,\s+(\d+(?:.\d+)?)%\)$/;
- _ref = pattern.exec(ui.color), h = _ref[1], s = _ref[2] / 100, l = _ref[3] / 100;
- that.model.setBackground(h, s, l, option);
- }
- });
-
- $('#background-colorpicker').colorPicker('setColor', 210, 50, 10);
-
- $("input[name=background]").change(function() {
- $('#background-colorpicker').change();
- });
-
- $('#background .alpha .ui-draggable').removeClass('ui-draggable handle');
-
- $('#foreground-colorpicker').colorPicker({
- format: 'hsl',
- size: 90,
- colorChange: function(e, ui) {
- var pattern, _ref, h, s, l;
- var option = $('input[name=foreground]:checked').val()
- pattern = /^hsl\((\d+),\s+(\d+(?:.\d+)?)%,\s+(\d+(?:.\d+)?)%\)$/;
- _ref = pattern.exec(ui.color), h = _ref[1], s = _ref[2] / 100, l = _ref[3] / 100;
- that.model.setForeground(h, s, l, option);
- }
- });
-
- $('#foreground-colorpicker').colorPicker('setColor', 210, 50, 90);
-
- $("input[name=foreground]").change(function() {
- $('#foreground-colorpicker').change();
- });
-
- $('#foreground .alpha .ui-draggable').removeClass('ui-draggable handle');
-
- $(".radio-group").buttonsetv();
-
- }
-
- });
-
- var schemeView = new SchemeView();
- var schemeCSSView = new SchemeCSSView();
- var schemeXresourcesView = new SchemeXresourcesView();
- var schemeKonsoleView = new SchemeKonsoleView();
- var schemeMinttyView = new SchemeMinttyView();
- var schemeITerm2View = new SchemeITerm2View();
- var schemeGuakeView = new SchemeGuakeView();
- var schemeGnomeTerminalView = new SchemeGnomeTerminalView();
- var schemeXfceTerminalView = new SchemeXfceTerminalView();
- var schemePuttyView = new SchemePuttyView();
- var schemeTerminatorView = new SchemeTerminatorView();
- var controlsView = new ControlsView();
-
- // basic layout behaviour /////////////////////////////
-
- $('footer p').hover(
- function() {
- $(this).find('a').addClass('blue');
- schemeCSSView.render();
- },
- function() {
- $(this).find('a').removeClass('blue');
- $(this).find('a').removeAttr("style");
- }
- );
-
- $(window).bind('load', function() {
- $('#display').css('visibility', 'visible');
- $('#controls').css('visibility', 'visible');
- $('#skews').fadeIn(700);
- $('#app').animate({opacity: 1}, 700);
- $('#get-scheme-button').click(function(button) {
- $('#dialog-modal').dialog({
- height: 90 + 50 * $('.get-scheme-link').length,
- width: 450,
- modal: true,
- draggable: false,
- resizable: false,
- open: function( event, ui ) {
- $('.ui-dialog').css('display', 'flex');
- }
- });
- });
- });
-
-}
diff --git a/less/main.less b/less/main.less
deleted file mode 100644
index 9d9c8cc..0000000
--- a/less/main.less
+++ /dev/null
@@ -1,534 +0,0 @@
-/* http://meyerweb.com/eric/tools/css/reset/
- v2.0 | 20110126
- License: none (public domain)
-*/
-
-html, body, div, span, applet, object, iframe,
-h1, h2, h3, h4, h5, h6, p, blockquote, pre,
-a, abbr, acronym, address, big, cite, code,
-del, dfn, em, img, ins, kbd, q, s, samp,
-small, strike, strong, sub, sup, tt, var,
-b, u, i, center,
-dl, dt, dd, ol, ul, li,
-fieldset, form, label, legend,
-table, caption, tbody, tfoot, thead, tr, th, td,
-article, aside, canvas, details, embed,
-figure, figcaption, footer, header, hgroup,
-menu, nav, output, ruby, section, summary,
-time, mark, audio, video {
- margin: 0;
- padding: 0;
- border: 0;
- font-size: 100%;
- font: inherit;
- vertical-align: baseline;
-}
-/* HTML5 display-role reset for older browsers */
-article, aside, details, figcaption, figure,
-footer, header, hgroup, menu, nav, section {
- display: block;
-}
-body {
- line-height: 1;
-}
-ol, ul {
- list-style: none;
-}
-blockquote, q {
- quotes: none;
-}
-blockquote:before, blockquote:after,
-q:before, q:after {
- content: '';
- content: none;
-}
-table {
- border-collapse: collapse;
- border-spacing: 0;
-}
-
-a {
- color: #000;
- text-decoration: none;
-}
-
-///////////////////////////////////////////////////////////////////////////////////////////////////
-
-@font-face {
- font-family: 'Rationale';
- src: url('../fonts/rationale/rationale-webfont.eot');
- src: url('../fonts/rationale/rationale-webfont.eot?#iefix') format('embedded-opentype'),
- url('../fonts/rationale/rationale-webfont.woff') format('woff'),
- url('../fonts/rationale/rationale-webfont.ttf') format('truetype'),
- url('../fonts/rationale/rationale-webfont.svg#webfontregular') format('svg');
- font-weight: normal;
- font-style: normal;
-}
-
-@font-face {
- font-family: 'Inconsolata';
- src: url('../fonts/inconsolata/ttf-inconsolata-webfont.eot');
- src: url('../fonts/inconsolata/ttf-inconsolata-webfont.eot?#iefix') format('embedded-opentype'),
- url('../fonts/inconsolata/ttf-inconsolata-webfont.woff') format('woff'),
- url('../fonts/inconsolata/ttf-inconsolata-webfont.ttf') format('truetype'),
- url('../fonts/inconsolata/ttf-inconsolata-webfont.svg#webfontregular') format('svg');
- font-weight: normal;
- font-style: normal;
-}
-
-///////////////////////////////////////////////////////////////////////////////////////////////////
-
-@app_width: 1190px;
-@app_height: 555px;
-@header_height: 60px;
-@footer_height: 40px;
-
-html, body {
- height: 100%;
- width: 100%;
-}
-
-body {
- background-color: #eee;
- font-family: 'Rationale', sans-serif;
- h1 {
- color: #777;
- font-size: 48px;
- display: inline-block;
- margin: 18px 0 0 20px;
- a {
- color: #777;
- }
- }
- header {
- position: relative;
- min-width: @app_width;
- height: @header_height;
- overflow: visible;
- }
- footer {
- // display: none;
- min-width: @app_width;
- height: @footer_height;
- overflow: visible;
- font-size: 20px;
- width: 100%;
- p {
- display: inline-block;
- }
- .left {
- float: left;
- margin-left: 20px;
- padding-bottom: 20px;
- img {
- width: 20px;
- height: 20px;
- margin: 0 0 -2px 0;
- }
- }
- .right {
- float: right;
- margin-right: 20px;
- padding-bottom: 20px;
- }
- .left, .right {
- opacity: 0.5;
- }
- .left:hover, .right:hover {
- opacity: 1;
- }
- }
-}
-
-.wrapper {
- min-height: @header_height + @app_height + @footer_height;
- height: 100%;
- margin: 0 auto (-@footer_height - 1px); /* the bottom margin is the negative value of the footer's height */
-}
-
-.distance {
- min-height: ((@app_height) / 2) - @header_height -10px;
- margin-bottom: -(((@app_height) / 2) + @header_height) + 10px;
- width: 1px;
- height: 50%;
- margin-top: 0;
- float: left;
-}
-
-.vertical-center {
- width: @app_width;
- height: @app_height;
- z-index: 1;
- position: relative;
- margin: 0 auto;
- clear: left;
-}
-
-.push {
- height: @footer_height;
-}
-
-.twitter-follow-button {
- display: inline-block;
- margin-bottom: -3px;
- width: 60px;
-}
-
-.twitter-follow-button, .twitter-share-button {
- font-size: 0;
-}
-
-#skews {
- display: none;
- position: absolute;
- top: 23px;
- right: 23px;
- height: 34px;
-}
-
-.skew {
- vertical-align: top;
- display: block;
- height: 34px;
- -webkit-transform: skew(-30deg);
- -moz-transform: skewX(-30deg);
- -o-transform: skew(-30deg);
- transform: skew(-30deg);
- border-radius: 6px;
- margin-right: 4px;
- margin-top: 0;
-}
-
-.skew > * {
- -webkit-transform: skew(30deg);
- -moz-transform: skewX(30deg);
- -o-transform: skew(30deg);
- transform: skew(30deg);
-}
-
-#social-media {
- border: 1px solid #AAA;
- background-color: #C9C9C9;
- position: relative;
- padding: 7px 15px 0 15px;
- width: 74px;
- height: 26px;
- display: inline-block;
- white-space: nowrap;
- .buttons {
- display: inline-block;
- }
- .inner {
- position: absolute;
- display: block;
- width: 74px;
- height: 20px;
- }
-}
-
-#get-scheme-button {
- width: 163px;
- border: 1px solid #ccc;
-
- span {
- white-space: nowrap;
- font-size: 20px;
- display: inline-block;
- -webkit-transform: skew(30deg);
- -moz-transform: skewX(30deg);
- -o-transform: skew(30deg);
- transform: skew(30deg);
- padding: 7px 20px 0 15px;
- }
-
- &:hover, &:active {
- border: 1px solid #bbb;
- }
-}
-
-#get-scheme-button, .get-scheme-link {
- display: inline-block;
- color: inherit;
- background: rgb(254,254,254); /* Old browsers */
- background: -moz-linear-gradient(top, rgba(254,254,254,1) 0%, rgba(223,223,223,1) 100%); /* FF3.6+ */
- background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(254,254,254,1)), color-stop(100%,rgba(223,223,223,1))); /* Chrome,Safari4+ */
- background: -webkit-linear-gradient(top, rgba(254,254,254,1) 0%,rgba(223,223,223,1) 100%); /* Chrome10+,Safari5.1+ */
- background: -o-linear-gradient(top, rgba(254,254,254,1) 0%,rgba(223,223,223,1) 100%); /* Opera 11.10+ */
- background: -ms-linear-gradient(top, rgba(254,254,254,1) 0%,rgba(223,223,223,1) 100%); /* IE10+ */
- background: linear-gradient(to bottom, rgba(254,254,254,1) 0%,rgba(223,223,223,1) 100%); /* W3C */
- filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#fefefe', endColorstr='#dfdfdf',GradientType=0 ); /* IE6-9 */
-
- &:hover {
- background: rgb(247,247,247); /* Old browsers */
- background: -moz-linear-gradient(top, rgba(247,247,247,1) 0%, rgba(224,224,224,1) 76%, rgba(218,218,218,1) 88%, rgba(209,209,209,1) 100%); /* FF3.6+ */
- background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(247,247,247,1)), color-stop(76%,rgba(224,224,224,1)), color-stop(88%,rgba(218,218,218,1)), color-stop(100%,rgba(209,209,209,1))); /* Chrome,Safari4+ */
- background: -webkit-linear-gradient(top, rgba(247,247,247,1) 0%,rgba(224,224,224,1) 76%,rgba(218,218,218,1) 88%,rgba(209,209,209,1) 100%); /* Chrome10+,Safari5.1+ */
- background: -o-linear-gradient(top, rgba(247,247,247,1) 0%,rgba(224,224,224,1) 76%,rgba(218,218,218,1) 88%,rgba(209,209,209,1) 100%); /* Opera 11.10+ */
- background: -ms-linear-gradient(top, rgba(247,247,247,1) 0%,rgba(224,224,224,1) 76%,rgba(218,218,218,1) 88%,rgba(209,209,209,1) 100%); /* IE10+ */
- background: linear-gradient(to bottom, rgba(247,247,247,1) 0%,rgba(224,224,224,1) 76%,rgba(218,218,218,1) 88%,rgba(209,209,209,1) 100%); /* W3C */
- filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#f7f7f7', endColorstr='#d1d1d1',GradientType=0 ); /* IE6-9 */
- }
- &:active {
- background: rgb(225,225,225); /* Old browsers */
- background: -moz-linear-gradient(top, rgba(225,225,225,1) 0%, rgba(225,225,225,1) 7%, rgba(232,232,232,1) 16%, rgba(238,238,238,1) 31%, rgba(218,218,218,1) 95%, rgba(217,217,217,1) 96%, rgba(187,187,187,1) 98%, rgba(187,187,187,1) 100%); /* FF3.6+ */
- background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(225,225,225,1)), color-stop(7%,rgba(225,225,225,1)), color-stop(16%,rgba(232,232,232,1)), color-stop(31%,rgba(238,238,238,1)), color-stop(95%,rgba(218,218,218,1)), color-stop(96%,rgba(217,217,217,1)), color-stop(98%,rgba(187,187,187,1)), color-stop(100%,rgba(187,187,187,1))); /* Chrome,Safari4+ */
- background: -webkit-linear-gradient(top, rgba(225,225,225,1) 0%,rgba(225,225,225,1) 7%,rgba(232,232,232,1) 16%,rgba(238,238,238,1) 31%,rgba(218,218,218,1) 95%,rgba(217,217,217,1) 96%,rgba(187,187,187,1) 98%,rgba(187,187,187,1) 100%); /* Chrome10+,Safari5.1+ */
- background: -o-linear-gradient(top, rgba(225,225,225,1) 0%,rgba(225,225,225,1) 7%,rgba(232,232,232,1) 16%,rgba(238,238,238,1) 31%,rgba(218,218,218,1) 95%,rgba(217,217,217,1) 96%,rgba(187,187,187,1) 98%,rgba(187,187,187,1) 100%); /* Opera 11.10+ */
- background: -ms-linear-gradient(top, rgba(225,225,225,1) 0%,rgba(225,225,225,1) 7%,rgba(232,232,232,1) 16%,rgba(238,238,238,1) 31%,rgba(218,218,218,1) 95%,rgba(217,217,217,1) 96%,rgba(187,187,187,1) 98%,rgba(187,187,187,1) 100%); /* IE10+ */
- background: linear-gradient(to bottom, rgba(225,225,225,1) 0%,rgba(225,225,225,1) 7%,rgba(232,232,232,1) 16%,rgba(238,238,238,1) 31%,rgba(218,218,218,1) 95%,rgba(217,217,217,1) 96%,rgba(187,187,187,1) 98%,rgba(187,187,187,1) 100%); /* W3C */
- filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#e1e1e1', endColorstr='#bbbbbb',GradientType=0 ); /* IE6-9 */
- }
-}
-
-.get-scheme-link {
- padding: 5px 10px;
- float: right;
- border: 1px solid #bbb;
- border-radius: 6px;
- &:hover, &:active {
- border: 1px solid #aaa;
- }
-}
-
-#dialog-modal {
- display: none;
-}
-
-#app {
- opacity: 0;
- white-space: nowrap;
-}
-
-#controls {
- vertical-align: top;
- visibility: hidden;
- display: inline-block;
- margin-left: 16px;
- width: 300px;
-
- h2 {
- font-size: 26px;
- margin: 20px 0 5px 0;
- }
-
- h3 {
- font-size: 18px;
- margin: 10px 0 5px 0;
- }
-
-}
-
-#advanced {
- font-size: 18px;
- font-family: 'Rationale', sans-serif;
- border: none;
- background: none;
-
- h2 {
- margin-bottom: 10px;
- }
-
- a {
- color: #000 !important;
- font-weight: normal;
- padding: 2px 16px 3px 16px;
- }
-
- &>.ui-tabs-nav>.ui-state-active {
- background: #C9C9C9 !important;
- }
-
- &>.ui-tabs-panel.ui-widget-content {
- background: #C9C9C9 !important;
- }
-
- &>.ui-tabs-nav {
- padding: 0 !important;
- border: none;
- background: #eee;
- border-radius: 0;
- }
-
- &>.ui-tabs-panel {
- padding: 18px 18px 16px 17px;
- min-height: 0 !important;
- border: 1px solid #999;
- -moz-border-radius-top-right: 6px;
- -webkit-border-top-right-radius: 6px;
- -khtml-border-top-right-radius: 6px;
- border-top-right-radius: 6px;
- }
-
- .radio-group {
- vertical-align: top;
- display: inline-block;
- margin: 0;
- }
-
- .colorpicker {
- display: inline-block;
- }
-}
-
-#controls>.ui-tabs {
- padding: 0 !important;
-}
-
-#display {
- visibility: hidden;
- display: inline-block;
- font-family: Inconsolata;
- font-size: 20px;
- margin: 26px 0 0 20px;
- width: auto;
- height: auto;
- padding: 1px 2px 1px 2px;
- -moz-box-shadow: 0 0 10px #666;
- -webkit-box-shadow: 0 0 10px #666;
- box-shadow: 0 0 10px #666;
-
- table {
- border-collapse: separate;
- border-spacing: 0.5em 0;
- margin-right: 0.5em;
- }
-
- td {
- margin-left: 1em;
- padding: 0 1em 0 1em;
- }
-
- .row-th {
- text-align: right;
- }
-}
-
-.bold {
- font-weight: bold; // opera sux
-}
-
-.ui-slider {
- font-size: 12px;
-}
-
-.ui-slider-handle {
- outline: 0 !important;
- cursor: pointer !important;
-}
-
-.colorInput {
- display: none;
-}
-
-.colorpicker {
- height: 90px;
-}
-
-#foreground, #background {
- .alpha {
- opacity: 0.3;
- }
-}
-
-.radio-group {
- margin: -1px 0 0 15px !important;
-
- input {
- display: none;
- }
-
- br {
- display: none;
- }
-
- label {
- display: block;
- height: 18px;
- width: 115px;
- margin: -2px 0 0 0;
- padding: 0;
- border: 1px solid #999 !important;
- .ui-button-text {
- font-family: Arial, Verdana, sans-serif;
- margin: 1px 0 0 0;
- padding: 0;
- font-size: 12px;
- color: #000;
- font-weight: normal;
- padding: 0 !important;
- }
- }
-}
-
-#dye-radio label {
- height: 23px !important;
- margin-bottom: -1px;
- .ui-button-text {
- margin: 3px 0 0 0;
- }
-}
-
-.ui-dialog {
- font-family: 'Rationale', sans-serif;
- background: #c9c9c9 !important;
- display: flex;
- flex-direction: column;
- max-height: 90vh;
-
- &>.ui-widget-header {
- padding-left: 15px;
- font-size: 26px;
- font-weight: normal;
- color: #000;
- background: #eee !important;
- }
-
- &>.ui-widget-content {
- overflow: scroll;
- background: #c9c9c9 !important;
- height: auto !important;
- padding: 1em 0.8em 0.8em 0.8em;
- }
-
- .ui-icon-closethick {
- background: url("../images/cross.png") 3px 3px no-repeat !important;
- }
-
- .ui-state-hover > .ui-icon-closethick {
- background: url("../images/cross_active.png") 3px 3px no-repeat !important;
- }
-
- ul {
- background-color: #eee;
- border-radius: 6px;
-
- li {
- padding: 10px 0 10px 0;
-
- p {
- font-size: 22px;
- position: relative;
- line-height: 30px;
- padding: 0 10px 0 10px;
- a {
- line-height: 1em;
- font-size: 18px;
- }
- }
- }
-
- li:nth-child(even) {
- background-color: #ddd;
- }
-
- li:last-child {
- border-bottom-left-radius: 6px;
- border-bottom-right-radius: 6px;
- }
- }
-
-}
-
-.ui-widget-overlay {
- background: #000;
-}
diff --git a/lib/google-closure-library b/lib/google-closure-library
deleted file mode 160000
index 9b8921b..0000000
--- a/lib/google-closure-library
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 9b8921b13f2228dd1c9a59b8bda2fe17302c041d
diff --git a/lib/js/backbone-min.js b/lib/js/backbone-min.js
deleted file mode 100755
index c1c0d4f..0000000
--- a/lib/js/backbone-min.js
+++ /dev/null
@@ -1,38 +0,0 @@
-// Backbone.js 0.9.2
-
-// (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc.
-// Backbone may be freely distributed under the MIT license.
-// For all details and documentation:
-// http://backbonejs.org
-(function(){var l=this,y=l.Backbone,z=Array.prototype.slice,A=Array.prototype.splice,g;g="undefined"!==typeof exports?exports:l.Backbone={};g.VERSION="0.9.2";var f=l._;!f&&"undefined"!==typeof require&&(f=require("underscore"));var i=l.jQuery||l.Zepto||l.ender;g.setDomLibrary=function(a){i=a};g.noConflict=function(){l.Backbone=y;return this};g.emulateHTTP=!1;g.emulateJSON=!1;var p=/\s+/,k=g.Events={on:function(a,b,c){var d,e,f,g,j;if(!b)return this;a=a.split(p);for(d=this._callbacks||(this._callbacks=
-{});e=a.shift();)f=(j=d[e])?j.tail:{},f.next=g={},f.context=c,f.callback=b,d[e]={tail:g,next:j?j.next:f};return this},off:function(a,b,c){var d,e,h,g,j,q;if(e=this._callbacks){if(!a&&!b&&!c)return delete this._callbacks,this;for(a=a?a.split(p):f.keys(e);d=a.shift();)if(h=e[d],delete e[d],h&&(b||c))for(g=h.tail;(h=h.next)!==g;)if(j=h.callback,q=h.context,b&&j!==b||c&&q!==c)this.on(d,j,q);return this}},trigger:function(a){var b,c,d,e,f,g;if(!(d=this._callbacks))return this;f=d.all;a=a.split(p);for(g=
-z.call(arguments,1);b=a.shift();){if(c=d[b])for(e=c.tail;(c=c.next)!==e;)c.callback.apply(c.context||this,g);if(c=f){e=c.tail;for(b=[b].concat(g);(c=c.next)!==e;)c.callback.apply(c.context||this,b)}}return this}};k.bind=k.on;k.unbind=k.off;var o=g.Model=function(a,b){var c;a||(a={});b&&b.parse&&(a=this.parse(a));if(c=n(this,"defaults"))a=f.extend({},c,a);b&&b.collection&&(this.collection=b.collection);this.attributes={};this._escapedAttributes={};this.cid=f.uniqueId("c");this.changed={};this._silent=
-{};this._pending={};this.set(a,{silent:!0});this.changed={};this._silent={};this._pending={};this._previousAttributes=f.clone(this.attributes);this.initialize.apply(this,arguments)};f.extend(o.prototype,k,{changed:null,_silent:null,_pending:null,idAttribute:"id",initialize:function(){},toJSON:function(){return f.clone(this.attributes)},get:function(a){return this.attributes[a]},escape:function(a){var b;if(b=this._escapedAttributes[a])return b;b=this.get(a);return this._escapedAttributes[a]=f.escape(null==
-b?"":""+b)},has:function(a){return null!=this.get(a)},set:function(a,b,c){var d,e;f.isObject(a)||null==a?(d=a,c=b):(d={},d[a]=b);c||(c={});if(!d)return this;d instanceof o&&(d=d.attributes);if(c.unset)for(e in d)d[e]=void 0;if(!this._validate(d,c))return!1;this.idAttribute in d&&(this.id=d[this.idAttribute]);var b=c.changes={},h=this.attributes,g=this._escapedAttributes,j=this._previousAttributes||{};for(e in d){a=d[e];if(!f.isEqual(h[e],a)||c.unset&&f.has(h,e))delete g[e],(c.silent?this._silent:
-b)[e]=!0;c.unset?delete h[e]:h[e]=a;!f.isEqual(j[e],a)||f.has(h,e)!=f.has(j,e)?(this.changed[e]=a,c.silent||(this._pending[e]=!0)):(delete this.changed[e],delete this._pending[e])}c.silent||this.change(c);return this},unset:function(a,b){(b||(b={})).unset=!0;return this.set(a,null,b)},clear:function(a){(a||(a={})).unset=!0;return this.set(f.clone(this.attributes),a)},fetch:function(a){var a=a?f.clone(a):{},b=this,c=a.success;a.success=function(d,e,f){if(!b.set(b.parse(d,f),a))return!1;c&&c(b,d)};
-a.error=g.wrapError(a.error,b,a);return(this.sync||g.sync).call(this,"read",this,a)},save:function(a,b,c){var d,e;f.isObject(a)||null==a?(d=a,c=b):(d={},d[a]=b);c=c?f.clone(c):{};if(c.wait){if(!this._validate(d,c))return!1;e=f.clone(this.attributes)}a=f.extend({},c,{silent:!0});if(d&&!this.set(d,c.wait?a:c))return!1;var h=this,i=c.success;c.success=function(a,b,e){b=h.parse(a,e);if(c.wait){delete c.wait;b=f.extend(d||{},b)}if(!h.set(b,c))return false;i?i(h,a):h.trigger("sync",h,a,c)};c.error=g.wrapError(c.error,
-h,c);b=this.isNew()?"create":"update";b=(this.sync||g.sync).call(this,b,this,c);c.wait&&this.set(e,a);return b},destroy:function(a){var a=a?f.clone(a):{},b=this,c=a.success,d=function(){b.trigger("destroy",b,b.collection,a)};if(this.isNew())return d(),!1;a.success=function(e){a.wait&&d();c?c(b,e):b.trigger("sync",b,e,a)};a.error=g.wrapError(a.error,b,a);var e=(this.sync||g.sync).call(this,"delete",this,a);a.wait||d();return e},url:function(){var a=n(this,"urlRoot")||n(this.collection,"url")||t();
-return this.isNew()?a:a+("/"==a.charAt(a.length-1)?"":"/")+encodeURIComponent(this.id)},parse:function(a){return a},clone:function(){return new this.constructor(this.attributes)},isNew:function(){return null==this.id},change:function(a){a||(a={});var b=this._changing;this._changing=!0;for(var c in this._silent)this._pending[c]=!0;var d=f.extend({},a.changes,this._silent);this._silent={};for(c in d)this.trigger("change:"+c,this,this.get(c),a);if(b)return this;for(;!f.isEmpty(this._pending);){this._pending=
-{};this.trigger("change",this,a);for(c in this.changed)!this._pending[c]&&!this._silent[c]&&delete this.changed[c];this._previousAttributes=f.clone(this.attributes)}this._changing=!1;return this},hasChanged:function(a){return!arguments.length?!f.isEmpty(this.changed):f.has(this.changed,a)},changedAttributes:function(a){if(!a)return this.hasChanged()?f.clone(this.changed):!1;var b,c=!1,d=this._previousAttributes,e;for(e in a)if(!f.isEqual(d[e],b=a[e]))(c||(c={}))[e]=b;return c},previous:function(a){return!arguments.length||
-!this._previousAttributes?null:this._previousAttributes[a]},previousAttributes:function(){return f.clone(this._previousAttributes)},isValid:function(){return!this.validate(this.attributes)},_validate:function(a,b){if(b.silent||!this.validate)return!0;var a=f.extend({},this.attributes,a),c=this.validate(a,b);if(!c)return!0;b&&b.error?b.error(this,c,b):this.trigger("error",this,c,b);return!1}});var r=g.Collection=function(a,b){b||(b={});b.model&&(this.model=b.model);b.comparator&&(this.comparator=b.comparator);
-this._reset();this.initialize.apply(this,arguments);a&&this.reset(a,{silent:!0,parse:b.parse})};f.extend(r.prototype,k,{model:o,initialize:function(){},toJSON:function(a){return this.map(function(b){return b.toJSON(a)})},add:function(a,b){var c,d,e,g,i,j={},k={},l=[];b||(b={});a=f.isArray(a)?a.slice():[a];c=0;for(d=a.length;c=b))this.iframe=i('').hide().appendTo("body")[0].contentWindow,this.navigate(a);this._hasPushState?i(window).bind("popstate",this.checkUrl):this._wantsHashChange&&"onhashchange"in window&&!b?i(window).bind("hashchange",this.checkUrl):this._wantsHashChange&&(this._checkUrlInterval=setInterval(this.checkUrl,
-this.interval));this.fragment=a;a=window.location;b=a.pathname==this.options.root;if(this._wantsHashChange&&this._wantsPushState&&!this._hasPushState&&!b)return this.fragment=this.getFragment(null,!0),window.location.replace(this.options.root+"#"+this.fragment),!0;this._wantsPushState&&this._hasPushState&&b&&a.hash&&(this.fragment=this.getHash().replace(s,""),window.history.replaceState({},document.title,a.protocol+"//"+a.host+this.options.root+this.fragment));if(!this.options.silent)return this.loadUrl()},
-stop:function(){i(window).unbind("popstate",this.checkUrl).unbind("hashchange",this.checkUrl);clearInterval(this._checkUrlInterval);m.started=!1},route:function(a,b){this.handlers.unshift({route:a,callback:b})},checkUrl:function(){var a=this.getFragment();a==this.fragment&&this.iframe&&(a=this.getFragment(this.getHash(this.iframe)));if(a==this.fragment)return!1;this.iframe&&this.navigate(a);this.loadUrl()||this.loadUrl(this.getHash())},loadUrl:function(a){var b=this.fragment=this.getFragment(a);return f.any(this.handlers,
-function(a){if(a.route.test(b))return a.callback(b),!0})},navigate:function(a,b){if(!m.started)return!1;if(!b||!0===b)b={trigger:b};var c=(a||"").replace(s,"");this.fragment!=c&&(this._hasPushState?(0!=c.indexOf(this.options.root)&&(c=this.options.root+c),this.fragment=c,window.history[b.replace?"replaceState":"pushState"]({},document.title,c)):this._wantsHashChange?(this.fragment=c,this._updateHash(window.location,c,b.replace),this.iframe&&c!=this.getFragment(this.getHash(this.iframe))&&(b.replace||
-this.iframe.document.open().close(),this._updateHash(this.iframe.location,c,b.replace))):window.location.assign(this.options.root+a),b.trigger&&this.loadUrl(a))},_updateHash:function(a,b,c){c?a.replace(a.toString().replace(/(javascript:|#).*$/,"")+"#"+b):a.hash=b}});var v=g.View=function(a){this.cid=f.uniqueId("view");this._configure(a||{});this._ensureElement();this.initialize.apply(this,arguments);this.delegateEvents()},F=/^(\S+)\s*(.*)$/,w="model,collection,el,id,attributes,className,tagName".split(",");
-f.extend(v.prototype,k,{tagName:"div",$:function(a){return this.$el.find(a)},initialize:function(){},render:function(){return this},remove:function(){this.$el.remove();return this},make:function(a,b,c){a=document.createElement(a);b&&i(a).attr(b);c&&i(a).html(c);return a},setElement:function(a,b){this.$el&&this.undelegateEvents();this.$el=a instanceof i?a:i(a);this.el=this.$el[0];!1!==b&&this.delegateEvents();return this},delegateEvents:function(a){if(a||(a=n(this,"events"))){this.undelegateEvents();
-for(var b in a){var c=a[b];f.isFunction(c)||(c=this[a[b]]);if(!c)throw Error('Method "'+a[b]+'" does not exist');var d=b.match(F),e=d[1],d=d[2],c=f.bind(c,this),e=e+(".delegateEvents"+this.cid);""===d?this.$el.bind(e,c):this.$el.delegate(d,e,c)}}},undelegateEvents:function(){this.$el.unbind(".delegateEvents"+this.cid)},_configure:function(a){this.options&&(a=f.extend({},this.options,a));for(var b=0,c=w.length;b=0===c})}function bk(a){var b=bl.split("|"),c=a.createDocumentFragment();if(c.createElement)while(b.length)c.createElement(b.pop());return c}function bC(a,b){return a.getElementsByTagName(b)[0]||a.appendChild(a.ownerDocument.createElement(b))}function bD(a,b){if(b.nodeType!==1||!p.hasData(a))return;var c,d,e,f=p._data(a),g=p._data(b,f),h=f.events;if(h){delete g.handle,g.events={};for(c in h)for(d=0,e=h[c].length;d").appendTo(e.body),c=b.css("display");b.remove();if(c==="none"||c===""){bI=e.body.appendChild(bI||p.extend(e.createElement("iframe"),{frameBorder:0,width:0,height:0}));if(!bJ||!bI.createElement)bJ=(bI.contentWindow||bI.contentDocument).document,bJ.write(""),bJ.close();b=bJ.body.appendChild(bJ.createElement(a)),c=bH(b,"display"),e.body.removeChild(bI)}return bR[a]=c,c}function ch(a,b,c,d){var e;if(p.isArray(b))p.each(b,function(b,e){c||cd.test(a)?d(a,e):ch(a+"["+(typeof e=="object"?b:"")+"]",e,c,d)});else if(!c&&p.type(b)==="object")for(e in b)ch(a+"["+e+"]",b[e],c,d);else d(a,b)}function cy(a){return function(b,c){typeof b!="string"&&(c=b,b="*");var d,e,f,g=b.toLowerCase().split(s),h=0,i=g.length;if(p.isFunction(c))for(;h)[^>]*$|#([\w\-]*)$)/,v=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,w=/^[\],:{}\s]*$/,x=/(?:^|:|,)(?:\s*\[)+/g,y=/\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g,z=/"[^"\\\r\n]*"|true|false|null|-?(?:\d\d*\.|)\d+(?:[eE][\-+]?\d+|)/g,A=/^-ms-/,B=/-([\da-z])/gi,C=function(a,b){return(b+"").toUpperCase()},D=function(){e.addEventListener?(e.removeEventListener("DOMContentLoaded",D,!1),p.ready()):e.readyState==="complete"&&(e.detachEvent("onreadystatechange",D),p.ready())},E={};p.fn=p.prototype={constructor:p,init:function(a,c,d){var f,g,h,i;if(!a)return this;if(a.nodeType)return this.context=this[0]=a,this.length=1,this;if(typeof a=="string"){a.charAt(0)==="<"&&a.charAt(a.length-1)===">"&&a.length>=3?f=[null,a,null]:f=u.exec(a);if(f&&(f[1]||!c)){if(f[1])return c=c instanceof p?c[0]:c,i=c&&c.nodeType?c.ownerDocument||c:e,a=p.parseHTML(f[1],i,!0),v.test(f[1])&&p.isPlainObject(c)&&this.attr.call(a,c,!0),p.merge(this,a);g=e.getElementById(f[2]);if(g&&g.parentNode){if(g.id!==f[2])return d.find(a);this.length=1,this[0]=g}return this.context=e,this.selector=a,this}return!c||c.jquery?(c||d).find(a):this.constructor(c).find(a)}return p.isFunction(a)?d.ready(a):(a.selector!==b&&(this.selector=a.selector,this.context=a.context),p.makeArray(a,this))},selector:"",jquery:"1.8.0",length:0,size:function(){return this.length},toArray:function(){return k.call(this)},get:function(a){return a==null?this.toArray():a<0?this[this.length+a]:this[a]},pushStack:function(a,b,c){var d=p.merge(this.constructor(),a);return d.prevObject=this,d.context=this.context,b==="find"?d.selector=this.selector+(this.selector?" ":"")+c:b&&(d.selector=this.selector+"."+b+"("+c+")"),d},each:function(a,b){return p.each(this,a,b)},ready:function(a){return p.ready.promise().done(a),this},eq:function(a){return a=+a,a===-1?this.slice(a):this.slice(a,a+1)},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},slice:function(){return this.pushStack(k.apply(this,arguments),"slice",k.call(arguments).join(","))},map:function(a){return this.pushStack(p.map(this,function(b,c){return a.call(b,c,b)}))},end:function(){return this.prevObject||this.constructor(null)},push:j,sort:[].sort,splice:[].splice},p.fn.init.prototype=p.fn,p.extend=p.fn.extend=function(){var a,c,d,e,f,g,h=arguments[0]||{},i=1,j=arguments.length,k=!1;typeof h=="boolean"&&(k=h,h=arguments[1]||{},i=2),typeof h!="object"&&!p.isFunction(h)&&(h={}),j===i&&(h=this,--i);for(;i0)return;d.resolveWith(e,[p]),p.fn.trigger&&p(e).trigger("ready").off("ready")},isFunction:function(a){return p.type(a)==="function"},isArray:Array.isArray||function(a){return p.type(a)==="array"},isWindow:function(a){return a!=null&&a==a.window},isNumeric:function(a){return!isNaN(parseFloat(a))&&isFinite(a)},type:function(a){return a==null?String(a):E[m.call(a)]||"object"},isPlainObject:function(a){if(!a||p.type(a)!=="object"||a.nodeType||p.isWindow(a))return!1;try{if(a.constructor&&!n.call(a,"constructor")&&!n.call(a.constructor.prototype,"isPrototypeOf"))return!1}catch(c){return!1}var d;for(d in a);return d===b||n.call(a,d)},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},error:function(a){throw new Error(a)},parseHTML:function(a,b,c){var d;return!a||typeof a!="string"?null:(typeof b=="boolean"&&(c=b,b=0),b=b||e,(d=v.exec(a))?[b.createElement(d[1])]:(d=p.buildFragment([a],b,c?null:[]),p.merge([],(d.cacheable?p.clone(d.fragment):d.fragment).childNodes)))},parseJSON:function(b){if(!b||typeof b!="string")return null;b=p.trim(b);if(a.JSON&&a.JSON.parse)return a.JSON.parse(b);if(w.test(b.replace(y,"@").replace(z,"]").replace(x,"")))return(new Function("return "+b))();p.error("Invalid JSON: "+b)},parseXML:function(c){var d,e;if(!c||typeof c!="string")return null;try{a.DOMParser?(e=new DOMParser,d=e.parseFromString(c,"text/xml")):(d=new ActiveXObject("Microsoft.XMLDOM"),d.async="false",d.loadXML(c))}catch(f){d=b}return(!d||!d.documentElement||d.getElementsByTagName("parsererror").length)&&p.error("Invalid XML: "+c),d},noop:function(){},globalEval:function(b){b&&r.test(b)&&(a.execScript||function(b){a.eval.call(a,b)})(b)},camelCase:function(a){return a.replace(A,"ms-").replace(B,C)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toUpperCase()===b.toUpperCase()},each:function(a,c,d){var e,f=0,g=a.length,h=g===b||p.isFunction(a);if(d){if(h){for(e in a)if(c.apply(a[e],d)===!1)break}else for(;f0&&a[0]&&a[i-1]||i===0||p.isArray(a));if(j)for(;h-1)i.splice(c,1),e&&(c<=g&&g--,c<=h&&h--)}),this},has:function(a){return p.inArray(a,i)>-1},empty:function(){return i=[],this},disable:function(){return i=j=c=b,this},disabled:function(){return!i},lock:function(){return j=b,c||l.disable(),this},locked:function(){return!j},fireWith:function(a,b){return b=b||[],b=[a,b.slice?b.slice():b],i&&(!d||j)&&(e?j.push(b):k(b)),this},fire:function(){return l.fireWith(this,arguments),this},fired:function(){return!!d}};return l},p.extend({Deferred:function(a){var b=[["resolve","done",p.Callbacks("once memory"),"resolved"],["reject","fail",p.Callbacks("once memory"),"rejected"],["notify","progress",p.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return p.Deferred(function(c){p.each(b,function(b,d){var f=d[0],g=a[b];e[d[1]](p.isFunction(g)?function(){var a=g.apply(this,arguments);a&&p.isFunction(a.promise)?a.promise().done(c.resolve).fail(c.reject).progress(c.notify):c[f+"With"](this===e?c:this,[a])}:c[f])}),a=null}).promise()},promise:function(a){return typeof a=="object"?p.extend(a,d):d}},e={};return d.pipe=d.then,p.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[a^1][2].disable,b[2][2].lock),e[f[0]]=g.fire,e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=k.call(arguments),d=c.length,e=d!==1||a&&p.isFunction(a.promise)?d:0,f=e===1?a:p.Deferred(),g=function(a,b,c){return function(d){b[a]=this,c[a]=arguments.length>1?k.call(arguments):d,c===h?f.notifyWith(b,c):--e||f.resolveWith(b,c)}},h,i,j;if(d>1){h=new Array(d),i=new Array(d),j=new Array(d);for(;ba ",c=n.getElementsByTagName("*"),d=n.getElementsByTagName("a")[0],d.style.cssText="top:1px;float:left;opacity:.5";if(!c||!c.length||!d)return{};f=e.createElement("select"),g=f.appendChild(e.createElement("option")),h=n.getElementsByTagName("input")[0],b={leadingWhitespace:n.firstChild.nodeType===3,tbody:!n.getElementsByTagName("tbody").length,htmlSerialize:!!n.getElementsByTagName("link").length,style:/top/.test(d.getAttribute("style")),hrefNormalized:d.getAttribute("href")==="/a",opacity:/^0.5/.test(d.style.opacity),cssFloat:!!d.style.cssFloat,checkOn:h.value==="on",optSelected:g.selected,getSetAttribute:n.className!=="t",enctype:!!e.createElement("form").enctype,html5Clone:e.createElement("nav").cloneNode(!0).outerHTML!=="<:nav>",boxModel:e.compatMode==="CSS1Compat",submitBubbles:!0,changeBubbles:!0,focusinBubbles:!1,deleteExpando:!0,noCloneEvent:!0,inlineBlockNeedsLayout:!1,shrinkWrapBlocks:!1,reliableMarginRight:!0,boxSizingReliable:!0,pixelPosition:!1},h.checked=!0,b.noCloneChecked=h.cloneNode(!0).checked,f.disabled=!0,b.optDisabled=!g.disabled;try{delete n.test}catch(o){b.deleteExpando=!1}!n.addEventListener&&n.attachEvent&&n.fireEvent&&(n.attachEvent("onclick",m=function(){b.noCloneEvent=!1}),n.cloneNode(!0).fireEvent("onclick"),n.detachEvent("onclick",m)),h=e.createElement("input"),h.value="t",h.setAttribute("type","radio"),b.radioValue=h.value==="t",h.setAttribute("checked","checked"),h.setAttribute("name","t"),n.appendChild(h),i=e.createDocumentFragment(),i.appendChild(n.lastChild),b.checkClone=i.cloneNode(!0).cloneNode(!0).lastChild.checked,b.appendChecked=h.checked,i.removeChild(h),i.appendChild(n);if(n.attachEvent)for(k in{submit:!0,change:!0,focusin:!0})j="on"+k,l=j in n,l||(n.setAttribute(j,"return;"),l=typeof n[j]=="function"),b[k+"Bubbles"]=l;return p(function(){var c,d,f,g,h="padding:0;margin:0;border:0;display:block;overflow:hidden;",i=e.getElementsByTagName("body")[0];if(!i)return;c=e.createElement("div"),c.style.cssText="visibility:hidden;border:0;width:0;height:0;position:static;top:0;margin-top:1px",i.insertBefore(c,i.firstChild),d=e.createElement("div"),c.appendChild(d),d.innerHTML="",f=d.getElementsByTagName("td"),f[0].style.cssText="padding:0;margin:0;border:0;display:none",l=f[0].offsetHeight===0,f[0].style.display="",f[1].style.display="none",b.reliableHiddenOffsets=l&&f[0].offsetHeight===0,d.innerHTML="",d.style.cssText="box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%;",b.boxSizing=d.offsetWidth===4,b.doesNotIncludeMarginInBodyOffset=i.offsetTop!==1,a.getComputedStyle&&(b.pixelPosition=(a.getComputedStyle(d,null)||{}).top!=="1%",b.boxSizingReliable=(a.getComputedStyle(d,null)||{width:"4px"}).width==="4px",g=e.createElement("div"),g.style.cssText=d.style.cssText=h,g.style.marginRight=g.style.width="0",d.style.width="1px",d.appendChild(g),b.reliableMarginRight=!parseFloat((a.getComputedStyle(g,null)||{}).marginRight)),typeof d.style.zoom!="undefined"&&(d.innerHTML="",d.style.cssText=h+"width:1px;padding:1px;display:inline;zoom:1",b.inlineBlockNeedsLayout=d.offsetWidth===3,d.style.display="block",d.style.overflow="visible",d.innerHTML="
",d.firstChild.style.width="5px",b.shrinkWrapBlocks=d.offsetWidth!==3,c.style.zoom=1),i.removeChild(c),c=d=f=g=null}),i.removeChild(n),c=d=f=g=h=i=n=null,b}();var H=/^(?:\{.*\}|\[.*\])$/,I=/([A-Z])/g;p.extend({cache:{},deletedIds:[],uuid:0,expando:"jQuery"+(p.fn.jquery+Math.random()).replace(/\D/g,""),noData:{embed:!0,object:"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000",applet:!0},hasData:function(a){return a=a.nodeType?p.cache[a[p.expando]]:a[p.expando],!!a&&!K(a)},data:function(a,c,d,e){if(!p.acceptData(a))return;var f,g,h=p.expando,i=typeof c=="string",j=a.nodeType,k=j?p.cache:a,l=j?a[h]:a[h]&&h;if((!l||!k[l]||!e&&!k[l].data)&&i&&d===b)return;l||(j?a[h]=l=p.deletedIds.pop()||++p.uuid:l=h),k[l]||(k[l]={},j||(k[l].toJSON=p.noop));if(typeof c=="object"||typeof c=="function")e?k[l]=p.extend(k[l],c):k[l].data=p.extend(k[l].data,c);return f=k[l],e||(f.data||(f.data={}),f=f.data),d!==b&&(f[p.camelCase(c)]=d),i?(g=f[c],g==null&&(g=f[p.camelCase(c)])):g=f,g},removeData:function(a,b,c){if(!p.acceptData(a))return;var d,e,f,g=a.nodeType,h=g?p.cache:a,i=g?a[p.expando]:p.expando;if(!h[i])return;if(b){d=c?h[i]:h[i].data;if(d){p.isArray(b)||(b in d?b=[b]:(b=p.camelCase(b),b in d?b=[b]:b=b.split(" ")));for(e=0,f=b.length;e1,null,!1))},removeData:function(a){return this.each(function(){p.removeData(this,a)})}}),p.extend({queue:function(a,b,c){var d;if(a)return b=(b||"fx")+"queue",d=p._data(a,b),c&&(!d||p.isArray(c)?d=p._data(a,b,p.makeArray(c)):d.push(c)),d||[]},dequeue:function(a,b){b=b||"fx";var c=p.queue(a,b),d=c.shift(),e=p._queueHooks(a,b),f=function(){p.dequeue(a,b)};d==="inprogress"&&(d=c.shift()),d&&(b==="fx"&&c.unshift("inprogress"),delete e.stop,d.call(a,f,e)),!c.length&&e&&e.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return p._data(a,c)||p._data(a,c,{empty:p.Callbacks("once memory").add(function(){p.removeData(a,b+"queue",!0),p.removeData(a,c,!0)})})}}),p.fn.extend({queue:function(a,c){var d=2;return typeof a!="string"&&(c=a,a="fx",d--),arguments.length1)},removeAttr:function(a){return this.each(function(){p.removeAttr(this,a)})},prop:function(a,b){return p.access(this,p.prop,a,b,arguments.length>1)},removeProp:function(a){return a=p.propFix[a]||a,this.each(function(){try{this[a]=b,delete this[a]}catch(c){}})},addClass:function(a){var b,c,d,e,f,g,h;if(p.isFunction(a))return this.each(function(b){p(this).addClass(a.call(this,b,this.className))});if(a&&typeof a=="string"){b=a.split(s);for(c=0,d=this.length;c-1)d=d.replace(" "+c[f]+" "," ");e.className=a?p.trim(d):""}}}return this},toggleClass:function(a,b){var c=typeof a,d=typeof b=="boolean";return p.isFunction(a)?this.each(function(c){p(this).toggleClass(a.call(this,c,this.className,b),b)}):this.each(function(){if(c==="string"){var e,f=0,g=p(this),h=b,i=a.split(s);while(e=i[f++])h=d?h:!g.hasClass(e),g[h?"addClass":"removeClass"](e)}else if(c==="undefined"||c==="boolean")this.className&&p._data(this,"__className__",this.className),this.className=this.className||a===!1?"":p._data(this,"__className__")||""})},hasClass:function(a){var b=" "+a+" ",c=0,d=this.length;for(;c-1)return!0;return!1},val:function(a){var c,d,e,f=this[0];if(!arguments.length){if(f)return c=p.valHooks[f.type]||p.valHooks[f.nodeName.toLowerCase()],c&&"get"in c&&(d=c.get(f,"value"))!==b?d:(d=f.value,typeof d=="string"?d.replace(P,""):d==null?"":d);return}return e=p.isFunction(a),this.each(function(d){var f,g=p(this);if(this.nodeType!==1)return;e?f=a.call(this,d,g.val()):f=a,f==null?f="":typeof f=="number"?f+="":p.isArray(f)&&(f=p.map(f,function(a){return a==null?"":a+""})),c=p.valHooks[this.type]||p.valHooks[this.nodeName.toLowerCase()];if(!c||!("set"in c)||c.set(this,f,"value")===b)this.value=f})}}),p.extend({valHooks:{option:{get:function(a){var b=a.attributes.value;return!b||b.specified?a.value:a.text}},select:{get:function(a){var b,c,d,e,f=a.selectedIndex,g=[],h=a.options,i=a.type==="select-one";if(f<0)return null;c=i?f:0,d=i?f+1:h.length;for(;c=0}),c.length||(a.selectedIndex=-1),c}}},attrFn:{},attr:function(a,c,d,e){var f,g,h,i=a.nodeType;if(!a||i===3||i===8||i===2)return;if(e&&p.isFunction(p.fn[c]))return p(a)[c](d);if(typeof a.getAttribute=="undefined")return p.prop(a,c,d);h=i!==1||!p.isXMLDoc(a),h&&(c=c.toLowerCase(),g=p.attrHooks[c]||(T.test(c)?M:L));if(d!==b){if(d===null){p.removeAttr(a,c);return}return g&&"set"in g&&h&&(f=g.set(a,d,c))!==b?f:(a.setAttribute(c,""+d),d)}return g&&"get"in g&&h&&(f=g.get(a,c))!==null?f:(f=a.getAttribute(c),f===null?b:f)},removeAttr:function(a,b){var c,d,e,f,g=0;if(b&&a.nodeType===1){d=b.split(s);for(;g=0}})});var V=/^(?:textarea|input|select)$/i,W=/^([^\.]*|)(?:\.(.+)|)$/,X=/(?:^|\s)hover(\.\S+|)\b/,Y=/^key/,Z=/^(?:mouse|contextmenu)|click/,$=/^(?:focusinfocus|focusoutblur)$/,_=function(a){return p.event.special.hover?a:a.replace(X,"mouseenter$1 mouseleave$1")};p.event={add:function(a,c,d,e,f){var g,h,i,j,k,l,m,n,o,q,r;if(a.nodeType===3||a.nodeType===8||!c||!d||!(g=p._data(a)))return;d.handler&&(o=d,d=o.handler,f=o.selector),d.guid||(d.guid=p.guid++),i=g.events,i||(g.events=i={}),h=g.handle,h||(g.handle=h=function(a){return typeof p!="undefined"&&(!a||p.event.triggered!==a.type)?p.event.dispatch.apply(h.elem,arguments):b},h.elem=a),c=p.trim(_(c)).split(" ");for(j=0;j=0&&(s=s.slice(0,-1),i=!0),s.indexOf(".")>=0&&(t=s.split("."),s=t.shift(),t.sort());if((!f||p.event.customEvent[s])&&!p.event.global[s])return;c=typeof c=="object"?c[p.expando]?c:new p.Event(s,c):new p.Event(s),c.type=s,c.isTrigger=!0,c.exclusive=i,c.namespace=t.join("."),c.namespace_re=c.namespace?new RegExp("(^|\\.)"+t.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,m=s.indexOf(":")<0?"on"+s:"";if(!f){h=p.cache;for(j in h)h[j].events&&h[j].events[s]&&p.event.trigger(c,d,h[j].handle.elem,!0);return}c.result=b,c.target||(c.target=f),d=d!=null?p.makeArray(d):[],d.unshift(c),n=p.event.special[s]||{};if(n.trigger&&n.trigger.apply(f,d)===!1)return;q=[[f,n.bindType||s]];if(!g&&!n.noBubble&&!p.isWindow(f)){r=n.delegateType||s,k=$.test(r+s)?f:f.parentNode;for(l=f;k;k=k.parentNode)q.push([k,r]),l=k;l===(f.ownerDocument||e)&&q.push([l.defaultView||l.parentWindow||a,r])}for(j=0;jq&&u.push({elem:this,matches:o.slice(q)});for(d=0;d0?this.on(b,null,a,c):this.trigger(b)},Y.test(b)&&(p.event.fixHooks[b]=p.event.keyHooks),Z.test(b)&&(p.event.fixHooks[b]=p.event.mouseHooks)}),function(a,b){function bd(a,b,c,d){var e=0,f=b.length;for(;e0?h(g,c,f):[]}function bf(a,c,d,e,f){var g,h,i,j,k,l,m,n,p=0,q=f.length,s=L.POS,t=new RegExp("^"+s.source+"(?!"+r+")","i"),u=function(){var a=1,c=arguments.length-2;for(;ai){m=a.slice(i,g.index),i=n,l=[c],B.test(m)&&(k&&(l=k),k=e);if(h=H.test(m))m=m.slice(0,-5).replace(B,"$&*");g.length>1&&g[0].replace(t,u),k=be(m,g[1],g[2],l,k,h)}}k?(j=j.concat(k),(m=a.slice(i))&&m!==")"?B.test(m)?bd(m,j,d,e):Z(m,c,d,e?e.concat(k):k):o.apply(d,j)):Z(a,c,d,e)}return q===1?d:Z.uniqueSort(d)}function bg(a,b,c){var d,e,f,g=[],i=0,j=D.exec(a),k=!j.pop()&&!j.pop(),l=k&&a.match(C)||[""],m=$.preFilter,n=$.filter,o=!c&&b!==h;for(;(e=l[i])!=null&&k;i++){g.push(d=[]),o&&(e=" "+e);while(e){k=!1;if(j=B.exec(e))e=e.slice(j[0].length),k=d.push({part:j.pop().replace(A," "),captures:j});for(f in n)(j=L[f].exec(e))&&(!m[f]||(j=m[f](j,b,c)))&&(e=e.slice(j.shift().length),k=d.push({part:f,captures:j}));if(!k)break}}return k||Z.error(a),g}function bh(a,b,e){var f=b.dir,g=m++;return a||(a=function(a){return a===e}),b.first?function(b,c){while(b=b[f])if(b.nodeType===1)return a(b,c)&&b}:function(b,e){var h,i=g+"."+d,j=i+"."+c;while(b=b[f])if(b.nodeType===1){if((h=b[q])===j)return b.sizset;if(typeof h=="string"&&h.indexOf(i)===0){if(b.sizset)return b}else{b[q]=j;if(a(b,e))return b.sizset=!0,b;b.sizset=!1}}}}function bi(a,b){return a?function(c,d){var e=b(c,d);return e&&a(e===!0?c:e,d)}:b}function bj(a,b,c){var d,e,f=0;for(;d=a[f];f++)$.relative[d.part]?e=bh(e,$.relative[d.part],b):(d.captures.push(b,c),e=bi(e,$.filter[d.part].apply(null,d.captures)));return e}function bk(a){return function(b,c){var d,e=0;for(;d=a[e];e++)if(d(b,c))return!0;return!1}}var c,d,e,f,g,h=a.document,i=h.documentElement,j="undefined",k=!1,l=!0,m=0,n=[].slice,o=[].push,q=("sizcache"+Math.random()).replace(".",""),r="[\\x20\\t\\r\\n\\f]",s="(?:\\\\.|[-\\w]|[^\\x00-\\xa0])+",t=s.replace("w","w#"),u="([*^$|!~]?=)",v="\\["+r+"*("+s+")"+r+"*(?:"+u+r+"*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|("+t+")|)|)"+r+"*\\]",w=":("+s+")(?:\\((?:(['\"])((?:\\\\.|[^\\\\])*?)\\2|((?:[^,]|\\\\,|(?:,(?=[^\\[]*\\]))|(?:,(?=[^\\(]*\\))))*))\\)|)",x=":(nth|eq|gt|lt|first|last|even|odd)(?:\\((\\d*)\\)|)(?=[^-]|$)",y=r+"*([\\x20\\t\\r\\n\\f>+~])"+r+"*",z="(?=[^\\x20\\t\\r\\n\\f])(?:\\\\.|"+v+"|"+w.replace(2,7)+"|[^\\\\(),])+",A=new RegExp("^"+r+"+|((?:^|[^\\\\])(?:\\\\.)*)"+r+"+$","g"),B=new RegExp("^"+y),C=new RegExp(z+"?(?="+r+"*,|$)","g"),D=new RegExp("^(?:(?!,)(?:(?:^|,)"+r+"*"+z+")*?|"+r+"*(.*?))(\\)|$)"),E=new RegExp(z.slice(19,-6)+"\\x20\\t\\r\\n\\f>+~])+|"+y,"g"),F=/^(?:#([\w\-]+)|(\w+)|\.([\w\-]+))$/,G=/[\x20\t\r\n\f]*[+~]/,H=/:not\($/,I=/h\d/i,J=/input|select|textarea|button/i,K=/\\(?!\\)/g,L={ID:new RegExp("^#("+s+")"),CLASS:new RegExp("^\\.("+s+")"),NAME:new RegExp("^\\[name=['\"]?("+s+")['\"]?\\]"),TAG:new RegExp("^("+s.replace("[-","[-\\*")+")"),ATTR:new RegExp("^"+v),PSEUDO:new RegExp("^"+w),CHILD:new RegExp("^:(only|nth|last|first)-child(?:\\("+r+"*(even|odd|(([+-]|)(\\d*)n|)"+r+"*(?:([+-]|)"+r+"*(\\d+)|))"+r+"*\\)|)","i"),POS:new RegExp(x,"ig"),needsContext:new RegExp("^"+r+"*[>+~]|"+x,"i")},M={},N=[],O={},P=[],Q=function(a){return a.sizzleFilter=!0,a},R=function(a){return function(b){return b.nodeName.toLowerCase()==="input"&&b.type===a}},S=function(a){return function(b){var c=b.nodeName.toLowerCase();return(c==="input"||c==="button")&&b.type===a}},T=function(a){var b=!1,c=h.createElement("div");try{b=a(c)}catch(d){}return c=null,b},U=T(function(a){a.innerHTML=" ";var b=typeof a.lastChild.getAttribute("multiple");return b!=="boolean"&&b!=="string"}),V=T(function(a){a.id=q+0,a.innerHTML="
",i.insertBefore(a,i.firstChild);var b=h.getElementsByName&&h.getElementsByName(q).length===2+h.getElementsByName(q+0).length;return g=!h.getElementById(q),i.removeChild(a),b}),W=T(function(a){return a.appendChild(h.createComment("")),a.getElementsByTagName("*").length===0}),X=T(function(a){return a.innerHTML=" ",a.firstChild&&typeof a.firstChild.getAttribute!==j&&a.firstChild.getAttribute("href")==="#"}),Y=T(function(a){return a.innerHTML="
",!a.getElementsByClassName||a.getElementsByClassName("e").length===0?!1:(a.lastChild.className="e",a.getElementsByClassName("e").length!==1)}),Z=function(a,b,c,d){c=c||[],b=b||h;var e,f,g,i,j=b.nodeType;if(j!==1&&j!==9)return[];if(!a||typeof a!="string")return c;g=ba(b);if(!g&&!d)if(e=F.exec(a))if(i=e[1]){if(j===9){f=b.getElementById(i);if(!f||!f.parentNode)return c;if(f.id===i)return c.push(f),c}else if(b.ownerDocument&&(f=b.ownerDocument.getElementById(i))&&bb(b,f)&&f.id===i)return c.push(f),c}else{if(e[2])return o.apply(c,n.call(b.getElementsByTagName(a),0)),c;if((i=e[3])&&Y&&b.getElementsByClassName)return o.apply(c,n.call(b.getElementsByClassName(i),0)),c}return bm(a,b,c,d,g)},$=Z.selectors={cacheLength:50,match:L,order:["ID","TAG"],attrHandle:{},createPseudo:Q,find:{ID:g?function(a,b,c){if(typeof b.getElementById!==j&&!c){var d=b.getElementById(a);return d&&d.parentNode?[d]:[]}}:function(a,c,d){if(typeof c.getElementById!==j&&!d){var e=c.getElementById(a);return e?e.id===a||typeof e.getAttributeNode!==j&&e.getAttributeNode("id").value===a?[e]:b:[]}},TAG:W?function(a,b){if(typeof b.getElementsByTagName!==j)return b.getElementsByTagName(a)}:function(a,b){var c=b.getElementsByTagName(a);if(a==="*"){var d,e=[],f=0;for(;d=c[f];f++)d.nodeType===1&&e.push(d);return e}return c}},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(K,""),a[3]=(a[4]||a[5]||"").replace(K,""),a[2]==="~="&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),a[1]==="nth"?(a[2]||Z.error(a[0]),a[3]=+(a[3]?a[4]+(a[5]||1):2*(a[2]==="even"||a[2]==="odd")),a[4]=+(a[6]+a[7]||a[2]==="odd")):a[2]&&Z.error(a[0]),a},PSEUDO:function(a){var b,c=a[4];return L.CHILD.test(a[0])?null:(c&&(b=D.exec(c))&&b.pop()&&(a[0]=a[0].slice(0,b[0].length-c.length-1),c=b[0].slice(0,-1)),a.splice(2,3,c||a[3]),a)}},filter:{ID:g?function(a){return a=a.replace(K,""),function(b){return b.getAttribute("id")===a}}:function(a){return a=a.replace(K,""),function(b){var c=typeof b.getAttributeNode!==j&&b.getAttributeNode("id");return c&&c.value===a}},TAG:function(a){return a==="*"?function(){return!0}:(a=a.replace(K,"").toLowerCase(),function(b){return b.nodeName&&b.nodeName.toLowerCase()===a})},CLASS:function(a){var b=M[a];return b||(b=M[a]=new RegExp("(^|"+r+")"+a+"("+r+"|$)"),N.push(a),N.length>$.cacheLength&&delete M[N.shift()]),function(a){return b.test(a.className||typeof a.getAttribute!==j&&a.getAttribute("class")||"")}},ATTR:function(a,b,c){return b?function(d){var e=Z.attr(d,a),f=e+"";if(e==null)return b==="!=";switch(b){case"=":return f===c;case"!=":return f!==c;case"^=":return c&&f.indexOf(c)===0;case"*=":return c&&f.indexOf(c)>-1;case"$=":return c&&f.substr(f.length-c.length)===c;case"~=":return(" "+f+" ").indexOf(c)>-1;case"|=":return f===c||f.substr(0,c.length+1)===c+"-"}}:function(b){return Z.attr(b,a)!=null}},CHILD:function(a,b,c,d){if(a==="nth"){var e=m++;return function(a){var b,f,g=0,h=a;if(c===1&&d===0)return!0;b=a.parentNode;if(b&&(b[q]!==e||!a.sizset)){for(h=b.firstChild;h;h=h.nextSibling)if(h.nodeType===1){h.sizset=++g;if(h===a)break}b[q]=e}return f=a.sizset-d,c===0?f===0:f%c===0&&f/c>=0}}return function(b){var c=b;switch(a){case"only":case"first":while(c=c.previousSibling)if(c.nodeType===1)return!1;if(a==="first")return!0;c=b;case"last":while(c=c.nextSibling)if(c.nodeType===1)return!1;return!0}}},PSEUDO:function(a,b,c,d){var e=$.pseudos[a]||$.pseudos[a.toLowerCase()];return e||Z.error("unsupported pseudo: "+a),e.sizzleFilter?e(b,c,d):e}},pseudos:{not:Q(function(a,b,c){var d=bl(a.replace(A,"$1"),b,c);return function(a){return!d(a)}}),enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return b==="input"&&!!a.checked||b==="option"&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},parent:function(a){return!$.pseudos.empty(a)},empty:function(a){var b;a=a.firstChild;while(a){if(a.nodeName>"@"||(b=a.nodeType)===3||b===4)return!1;a=a.nextSibling}return!0},contains:Q(function(a){return function(b){return(b.textContent||b.innerText||bc(b)).indexOf(a)>-1}}),has:Q(function(a){return function(b){return Z(a,b).length>0}}),header:function(a){return I.test(a.nodeName)},text:function(a){var b,c;return a.nodeName.toLowerCase()==="input"&&(b=a.type)==="text"&&((c=a.getAttribute("type"))==null||c.toLowerCase()===b)},radio:R("radio"),checkbox:R("checkbox"),file:R("file"),password:R("password"),image:R("image"),submit:S("submit"),reset:S("reset"),button:function(a){var b=a.nodeName.toLowerCase();return b==="input"&&a.type==="button"||b==="button"},input:function(a){return J.test(a.nodeName)},focus:function(a){var b=a.ownerDocument;return a===b.activeElement&&(!b.hasFocus||b.hasFocus())&&(!!a.type||!!a.href)},active:function(a){return a===a.ownerDocument.activeElement}},setFilters:{first:function(a,b,c){return c?a.slice(1):[a[0]]},last:function(a,b,c){var d=a.pop();return c?a:[d]},even:function(a,b,c){var d=[],e=c?1:0,f=a.length;for(;e$.cacheLength&&delete O[P.shift()],g};Z.matches=function(a,b){return Z(a,null,null,b)},Z.matchesSelector=function(a,b){return Z(b,null,null,[a]).length>0};var bm=function(a,b,e,f,g){a=a.replace(A,"$1");var h,i,j,k,l,m,p,q,r,s=a.match(C),t=a.match(E),u=b.nodeType;if(L.POS.test(a))return bf(a,b,e,f,s);if(f)h=n.call(f,0);else if(s&&s.length===1){if(t.length>1&&u===9&&!g&&(s=L.ID.exec(t[0]))){b=$.find.ID(s[1],b,g)[0];if(!b)return e;a=a.slice(t.shift().length)}q=(s=G.exec(t[0]))&&!s.index&&b.parentNode||b,r=t.pop(),m=r.split(":not")[0];for(j=0,k=$.order.length;j ",a.querySelectorAll("[selected]").length||e.push("\\["+r+"*(?:checked|disabled|ismap|multiple|readonly|selected|value)"),a.querySelectorAll(":checked").length||e.push(":checked")}),T(function(a){a.innerHTML="
",a.querySelectorAll("[test^='']").length&&e.push("[*^$]="+r+"*(?:\"\"|'')"),a.innerHTML=" ",a.querySelectorAll(":enabled").length||e.push(":enabled",":disabled")}),e=e.length&&new RegExp(e.join("|")),bm=function(a,d,f,g,h){if(!g&&!h&&(!e||!e.test(a)))if(d.nodeType===9)try{return o.apply(f,n.call(d.querySelectorAll(a),0)),f}catch(i){}else if(d.nodeType===1&&d.nodeName.toLowerCase()!=="object"){var j=d.getAttribute("id"),k=j||q,l=G.test(a)&&d.parentNode||d;j?k=k.replace(c,"\\$&"):d.setAttribute("id",k);try{return o.apply(f,n.call(l.querySelectorAll(a.replace(C,"[id='"+k+"'] $&")),0)),f}catch(i){}finally{j||d.removeAttribute("id")}}return b(a,d,f,g,h)},g&&(T(function(b){a=g.call(b,"div");try{g.call(b,"[test!='']:sizzle"),f.push($.match.PSEUDO)}catch(c){}}),f=new RegExp(f.join("|")),Z.matchesSelector=function(b,c){c=c.replace(d,"='$1']");if(!ba(b)&&!f.test(c)&&(!e||!e.test(c)))try{var h=g.call(b,c);if(h||a||b.document&&b.document.nodeType!==11)return h}catch(i){}return Z(c,null,null,[b]).length>0})}(),Z.attr=p.attr,p.find=Z,p.expr=Z.selectors,p.expr[":"]=p.expr.pseudos,p.unique=Z.uniqueSort,p.text=Z.getText,p.isXMLDoc=Z.isXML,p.contains=Z.contains}(a);var bc=/Until$/,bd=/^(?:parents|prev(?:Until|All))/,be=/^.[^:#\[\.,]*$/,bf=p.expr.match.needsContext,bg={children:!0,contents:!0,next:!0,prev:!0};p.fn.extend({find:function(a){var b,c,d,e,f,g,h=this;if(typeof a!="string")return p(a).filter(function(){for(b=0,c=h.length;b0)for(e=d;e=0:p.filter(a,this).length>0:this.filter(a).length>0)},closest:function(a,b){var c,d=0,e=this.length,f=[],g=bf.test(a)||typeof a!="string"?p(a,b||this.context):0;for(;d-1:p.find.matchesSelector(c,a)){f.push(c);break}c=c.parentNode}}return f=f.length>1?p.unique(f):f,this.pushStack(f,"closest",a)},index:function(a){return a?typeof a=="string"?p.inArray(this[0],p(a)):p.inArray(a.jquery?a[0]:a,this):this[0]&&this[0].parentNode?this.prevAll().length:-1},add:function(a,b){var c=typeof a=="string"?p(a,b):p.makeArray(a&&a.nodeType?[a]:a),d=p.merge(this.get(),c);return this.pushStack(bh(c[0])||bh(d[0])?d:p.unique(d))},addBack:function(a){return this.add(a==null?this.prevObject:this.prevObject.filter(a))}}),p.fn.andSelf=p.fn.addBack,p.each({parent:function(a){var b=a.parentNode;return b&&b.nodeType!==11?b:null},parents:function(a){return p.dir(a,"parentNode")},parentsUntil:function(a,b,c){return p.dir(a,"parentNode",c)},next:function(a){return bi(a,"nextSibling")},prev:function(a){return bi(a,"previousSibling")},nextAll:function(a){return p.dir(a,"nextSibling")},prevAll:function(a){return p.dir(a,"previousSibling")},nextUntil:function(a,b,c){return p.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return p.dir(a,"previousSibling",c)},siblings:function(a){return p.sibling((a.parentNode||{}).firstChild,a)},children:function(a){return p.sibling(a.firstChild)},contents:function(a){return p.nodeName(a,"iframe")?a.contentDocument||a.contentWindow.document:p.merge([],a.childNodes)}},function(a,b){p.fn[a]=function(c,d){var e=p.map(this,b,c);return bc.test(a)||(d=c),d&&typeof d=="string"&&(e=p.filter(d,e)),e=this.length>1&&!bg[a]?p.unique(e):e,this.length>1&&bd.test(a)&&(e=e.reverse()),this.pushStack(e,a,k.call(arguments).join(","))}}),p.extend({filter:function(a,b,c){return c&&(a=":not("+a+")"),b.length===1?p.find.matchesSelector(b[0],a)?[b[0]]:[]:p.find.matches(a,b)},dir:function(a,c,d){var e=[],f=a[c];while(f&&f.nodeType!==9&&(d===b||f.nodeType!==1||!p(f).is(d)))f.nodeType===1&&e.push(f),f=f[c];return e},sibling:function(a,b){var c=[];for(;a;a=a.nextSibling)a.nodeType===1&&a!==b&&c.push(a);return c}});var bl="abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|header|hgroup|mark|meter|nav|output|progress|section|summary|time|video",bm=/ jQuery\d+="(?:null|\d+)"/g,bn=/^\s+/,bo=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,bp=/<([\w:]+)/,bq=/ ]","i"),bv=/^(?:checkbox|radio)$/,bw=/checked\s*(?:[^=]|=\s*.checked.)/i,bx=/\/(java|ecma)script/i,by=/^\s*\s*$/g,bz={option:[1,""," "],legend:[1,""," "],thead:[1,""],tr:[2,""],td:[3,""],col:[2,""],area:[1,""," "],_default:[0,"",""]},bA=bk(e),bB=bA.appendChild(e.createElement("div"));bz.optgroup=bz.option,bz.tbody=bz.tfoot=bz.colgroup=bz.caption=bz.thead,bz.th=bz.td,p.support.htmlSerialize||(bz._default=[1,"X","
"]),p.fn.extend({text:function(a){return p.access(this,function(a){return a===b?p.text(this):this.empty().append((this[0]&&this[0].ownerDocument||e).createTextNode(a))},null,a,arguments.length)},wrapAll:function(a){if(p.isFunction(a))return this.each(function(b){p(this).wrapAll(a.call(this,b))});if(this[0]){var b=p(a,this[0].ownerDocument).eq(0).clone(!0);this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstChild&&a.firstChild.nodeType===1)a=a.firstChild;return a}).append(this)}return this},wrapInner:function(a){return p.isFunction(a)?this.each(function(b){p(this).wrapInner(a.call(this,b))}):this.each(function(){var b=p(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=p.isFunction(a);return this.each(function(c){p(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(){return this.parent().each(function(){p.nodeName(this,"body")||p(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,!0,function(a){(this.nodeType===1||this.nodeType===11)&&this.appendChild(a)})},prepend:function(){return this.domManip(arguments,!0,function(a){(this.nodeType===1||this.nodeType===11)&&this.insertBefore(a,this.firstChild)})},before:function(){if(!bh(this[0]))return this.domManip(arguments,!1,function(a){this.parentNode.insertBefore(a,this)});if(arguments.length){var a=p.clean(arguments);return this.pushStack(p.merge(a,this),"before",this.selector)}},after:function(){if(!bh(this[0]))return this.domManip(arguments,!1,function(a){this.parentNode.insertBefore(a,this.nextSibling)});if(arguments.length){var a=p.clean(arguments);return this.pushStack(p.merge(this,a),"after",this.selector)}},remove:function(a,b){var c,d=0;for(;(c=this[d])!=null;d++)if(!a||p.filter(a,[c]).length)!b&&c.nodeType===1&&(p.cleanData(c.getElementsByTagName("*")),p.cleanData([c])),c.parentNode&&c.parentNode.removeChild(c);return this},empty:function(){var a,b=0;for(;(a=this[b])!=null;b++){a.nodeType===1&&p.cleanData(a.getElementsByTagName("*"));while(a.firstChild)a.removeChild(a.firstChild)}return this},clone:function(a,b){return a=a==null?!1:a,b=b==null?a:b,this.map(function(){return p.clone(this,a,b)})},html:function(a){return p.access(this,function(a){var c=this[0]||{},d=0,e=this.length;if(a===b)return c.nodeType===1?c.innerHTML.replace(bm,""):b;if(typeof a=="string"&&!bs.test(a)&&(p.support.htmlSerialize||!bu.test(a))&&(p.support.leadingWhitespace||!bn.test(a))&&!bz[(bp.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(bo,"<$1>$2>");try{for(;d1&&typeof j=="string"&&bw.test(j))return this.each(function(){p(this).domManip(a,c,d)});if(p.isFunction(j))return this.each(function(e){var f=p(this);a[0]=j.call(this,e,c?f.html():b),f.domManip(a,c,d)});if(this[0]){e=p.buildFragment(a,this,k),g=e.fragment,f=g.firstChild,g.childNodes.length===1&&(g=f);if(f){c=c&&p.nodeName(f,"tr");for(h=e.cacheable||l-1;i0?this.clone(!0):this).get(),p(g[e])[b](d),f=f.concat(d);return this.pushStack(f,a,g.selector)}}),p.extend({clone:function(a,b,c){var d,e,f,g;p.support.html5Clone||p.isXMLDoc(a)||!bu.test("<"+a.nodeName+">")?g=a.cloneNode(!0):(bB.innerHTML=a.outerHTML,bB.removeChild(g=bB.firstChild));if((!p.support.noCloneEvent||!p.support.noCloneChecked)&&(a.nodeType===1||a.nodeType===11)&&!p.isXMLDoc(a)){bE(a,g),d=bF(a),e=bF(g);for(f=0;d[f];++f)e[f]&&bE(d[f],e[f])}if(b){bD(a,g);if(c){d=bF(a),e=bF(g);for(f=0;d[f];++f)bD(d[f],e[f])}}return d=e=null,g},clean:function(a,b,c,d){var f,g,h,i,j,k,l,m,n,o,q,r,s=0,t=[];if(!b||typeof b.createDocumentFragment=="undefined")b=e;for(g=b===e&&bA;(h=a[s])!=null;s++){typeof h=="number"&&(h+="");if(!h)continue;if(typeof h=="string")if(!br.test(h))h=b.createTextNode(h);else{g=g||bk(b),l=l||g.appendChild(b.createElement("div")),h=h.replace(bo,"<$1>$2>"),i=(bp.exec(h)||["",""])[1].toLowerCase(),j=bz[i]||bz._default,k=j[0],l.innerHTML=j[1]+h+j[2];while(k--)l=l.lastChild;if(!p.support.tbody){m=bq.test(h),n=i==="table"&&!m?l.firstChild&&l.firstChild.childNodes:j[1]===""&&!m?l.childNodes:[];for(f=n.length-1;f>=0;--f)p.nodeName(n[f],"tbody")&&!n[f].childNodes.length&&n[f].parentNode.removeChild(n[f])}!p.support.leadingWhitespace&&bn.test(h)&&l.insertBefore(b.createTextNode(bn.exec(h)[0]),l.firstChild),h=l.childNodes,l=g.lastChild}h.nodeType?t.push(h):t=p.merge(t,h)}l&&(g.removeChild(l),h=l=g=null);if(!p.support.appendChecked)for(s=0;(h=t[s])!=null;s++)p.nodeName(h,"input")?bG(h):typeof h.getElementsByTagName!="undefined"&&p.grep(h.getElementsByTagName("input"),bG);if(c){q=function(a){if(!a.type||bx.test(a.type))return d?d.push(a.parentNode?a.parentNode.removeChild(a):a):c.appendChild(a)};for(s=0;(h=t[s])!=null;s++)if(!p.nodeName(h,"script")||!q(h))c.appendChild(h),typeof h.getElementsByTagName!="undefined"&&(r=p.grep(p.merge([],h.getElementsByTagName("script")),q),t.splice.apply(t,[s+1,0].concat(r)),s+=r.length)}return t},cleanData:function(a,b){var c,d,e,f,g=0,h=p.expando,i=p.cache,j=p.support.deleteExpando,k=p.event.special;for(;(e=a[g])!=null;g++)if(b||p.acceptData(e)){d=e[h],c=d&&i[d];if(c){if(c.events)for(f in c.events)k[f]?p.event.remove(e,f):p.removeEvent(e,f,c.handle);i[d]&&(delete i[d],j?delete e[h]:e.removeAttribute?e.removeAttribute(h):e[h]=null,p.deletedIds.push(d))}}}}),function(){var a,b;p.uaMatch=function(a){a=a.toLowerCase();var b=/(chrome)[ \/]([\w.]+)/.exec(a)||/(webkit)[ \/]([\w.]+)/.exec(a)||/(opera)(?:.*version|)[ \/]([\w.]+)/.exec(a)||/(msie) ([\w.]+)/.exec(a)||a.indexOf("compatible")<0&&/(mozilla)(?:.*? rv:([\w.]+)|)/.exec(a)||[];return{browser:b[1]||"",version:b[2]||"0"}},a=p.uaMatch(g.userAgent),b={},a.browser&&(b[a.browser]=!0,b.version=a.version),b.webkit&&(b.safari=!0),p.browser=b,p.sub=function(){function a(b,c){return new a.fn.init(b,c)}p.extend(!0,a,this),a.superclass=this,a.fn=a.prototype=this(),a.fn.constructor=a,a.sub=this.sub,a.fn.init=function c(c,d){return d&&d instanceof p&&!(d instanceof a)&&(d=a(d)),p.fn.init.call(this,c,d,b)},a.fn.init.prototype=a.fn;var b=a(e);return a}}();var bH,bI,bJ,bK=/alpha\([^)]*\)/i,bL=/opacity=([^)]*)/,bM=/^(top|right|bottom|left)$/,bN=/^margin/,bO=new RegExp("^("+q+")(.*)$","i"),bP=new RegExp("^("+q+")(?!px)[a-z%]+$","i"),bQ=new RegExp("^([-+])=("+q+")","i"),bR={},bS={position:"absolute",visibility:"hidden",display:"block"},bT={letterSpacing:0,fontWeight:400,lineHeight:1},bU=["Top","Right","Bottom","Left"],bV=["Webkit","O","Moz","ms"],bW=p.fn.toggle;p.fn.extend({css:function(a,c){return p.access(this,function(a,c,d){return d!==b?p.style(a,c,d):p.css(a,c)},a,c,arguments.length>1)},show:function(){return bZ(this,!0)},hide:function(){return bZ(this)},toggle:function(a,b){var c=typeof a=="boolean";return p.isFunction(a)&&p.isFunction(b)?bW.apply(this,arguments):this.each(function(){(c?a:bY(this))?p(this).show():p(this).hide()})}}),p.extend({cssHooks:{opacity:{get:function(a,b){if(b){var c=bH(a,"opacity");return c===""?"1":c}}}},cssNumber:{fillOpacity:!0,fontWeight:!0,lineHeight:!0,opacity:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":p.support.cssFloat?"cssFloat":"styleFloat"},style:function(a,c,d,e){if(!a||a.nodeType===3||a.nodeType===8||!a.style)return;var f,g,h,i=p.camelCase(c),j=a.style;c=p.cssProps[i]||(p.cssProps[i]=bX(j,i)),h=p.cssHooks[c]||p.cssHooks[i];if(d===b)return h&&"get"in h&&(f=h.get(a,!1,e))!==b?f:j[c];g=typeof d,g==="string"&&(f=bQ.exec(d))&&(d=(f[1]+1)*f[2]+parseFloat(p.css(a,c)),g="number");if(d==null||g==="number"&&isNaN(d))return;g==="number"&&!p.cssNumber[i]&&(d+="px");if(!h||!("set"in h)||(d=h.set(a,d,e))!==b)try{j[c]=d}catch(k){}},css:function(a,c,d,e){var f,g,h,i=p.camelCase(c);return c=p.cssProps[i]||(p.cssProps[i]=bX(a.style,i)),h=p.cssHooks[c]||p.cssHooks[i],h&&"get"in h&&(f=h.get(a,!0,e)),f===b&&(f=bH(a,c)),f==="normal"&&c in bT&&(f=bT[c]),d||e!==b?(g=parseFloat(f),d||p.isNumeric(g)?g||0:f):f},swap:function(a,b,c){var d,e,f={};for(e in b)f[e]=a.style[e],a.style[e]=b[e];d=c.call(a);for(e in b)a.style[e]=f[e];return d}}),a.getComputedStyle?bH=function(a,b){var c,d,e,f,g=getComputedStyle(a,null),h=a.style;return g&&(c=g[b],c===""&&!p.contains(a.ownerDocument.documentElement,a)&&(c=p.style(a,b)),bP.test(c)&&bN.test(b)&&(d=h.width,e=h.minWidth,f=h.maxWidth,h.minWidth=h.maxWidth=h.width=c,c=g.width,h.width=d,h.minWidth=e,h.maxWidth=f)),c}:e.documentElement.currentStyle&&(bH=function(a,b){var c,d,e=a.currentStyle&&a.currentStyle[b],f=a.style;return e==null&&f&&f[b]&&(e=f[b]),bP.test(e)&&!bM.test(b)&&(c=f.left,d=a.runtimeStyle&&a.runtimeStyle.left,d&&(a.runtimeStyle.left=a.currentStyle.left),f.left=b==="fontSize"?"1em":e,e=f.pixelLeft+"px",f.left=c,d&&(a.runtimeStyle.left=d)),e===""?"auto":e}),p.each(["height","width"],function(a,b){p.cssHooks[b]={get:function(a,c,d){if(c)return a.offsetWidth!==0||bH(a,"display")!=="none"?ca(a,b,d):p.swap(a,bS,function(){return ca(a,b,d)})},set:function(a,c,d){return b$(a,c,d?b_(a,b,d,p.support.boxSizing&&p.css(a,"boxSizing")==="border-box"):0)}}}),p.support.opacity||(p.cssHooks.opacity={get:function(a,b){return bL.test((b&&a.currentStyle?a.currentStyle.filter:a.style.filter)||"")?.01*parseFloat(RegExp.$1)+"":b?"1":""},set:function(a,b){var c=a.style,d=a.currentStyle,e=p.isNumeric(b)?"alpha(opacity="+b*100+")":"",f=d&&d.filter||c.filter||"";c.zoom=1;if(b>=1&&p.trim(f.replace(bK,""))===""&&c.removeAttribute){c.removeAttribute("filter");if(d&&!d.filter)return}c.filter=bK.test(f)?f.replace(bK,e):f+" "+e}}),p(function(){p.support.reliableMarginRight||(p.cssHooks.marginRight={get:function(a,b){return p.swap(a,{display:"inline-block"},function(){if(b)return bH(a,"marginRight")})}}),!p.support.pixelPosition&&p.fn.position&&p.each(["top","left"],function(a,b){p.cssHooks[b]={get:function(a,c){if(c){var d=bH(a,b);return bP.test(d)?p(a).position()[b]+"px":d}}}})}),p.expr&&p.expr.filters&&(p.expr.filters.hidden=function(a){return a.offsetWidth===0&&a.offsetHeight===0||!p.support.reliableHiddenOffsets&&(a.style&&a.style.display||bH(a,"display"))==="none"},p.expr.filters.visible=function(a){return!p.expr.filters.hidden(a)}),p.each({margin:"",padding:"",border:"Width"},function(a,b){p.cssHooks[a+b]={expand:function(c){var d,e=typeof c=="string"?c.split(" "):[c],f={};for(d=0;d<4;d++)f[a+bU[d]+b]=e[d]||e[d-2]||e[0];return f}},bN.test(a)||(p.cssHooks[a+b].set=b$)});var cc=/%20/g,cd=/\[\]$/,ce=/\r?\n/g,cf=/^(?:color|date|datetime|datetime-local|email|hidden|month|number|password|range|search|tel|text|time|url|week)$/i,cg=/^(?:select|textarea)/i;p.fn.extend({serialize:function(){return p.param(this.serializeArray())},serializeArray:function(){return this.map(function(){return this.elements?p.makeArray(this.elements):this}).filter(function(){return this.name&&!this.disabled&&(this.checked||cg.test(this.nodeName)||cf.test(this.type))}).map(function(a,b){var c=p(this).val();return c==null?null:p.isArray(c)?p.map(c,function(a,c){return{name:b.name,value:a.replace(ce,"\r\n")}}):{name:b.name,value:c.replace(ce,"\r\n")}}).get()}}),p.param=function(a,c){var d,e=[],f=function(a,b){b=p.isFunction(b)?b():b==null?"":b,e[e.length]=encodeURIComponent(a)+"="+encodeURIComponent(b)};c===b&&(c=p.ajaxSettings&&p.ajaxSettings.traditional);if(p.isArray(a)||a.jquery&&!p.isPlainObject(a))p.each(a,function(){f(this.name,this.value)});else for(d in a)ch(d,a[d],c,f);return e.join("&").replace(cc,"+")};var ci,cj,ck=/#.*$/,cl=/^(.*?):[ \t]*([^\r\n]*)\r?$/mg,cm=/^(?:about|app|app\-storage|.+\-extension|file|res|widget):$/,cn=/^(?:GET|HEAD)$/,co=/^\/\//,cp=/\?/,cq=/
+
+
diff --git a/src/presentation/about-page/main.js b/src/presentation/about-page/main.js
new file mode 100644
index 0000000..fd4663c
--- /dev/null
+++ b/src/presentation/about-page/main.js
@@ -0,0 +1,14 @@
+import '../shared/assets/styles/reset.less';
+import '../shared/assets/styles/fonts.less';
+import '../shared/assets/styles/base.less';
+import '../shared/assets/styles/page-shell.less';
+
+import { createPinia } from 'pinia';
+import { createApp } from 'vue';
+import AboutPage from './AboutPage.vue';
+import { initializeAboutPageTheme } from './theme-bootstrap';
+
+const pinia = createPinia();
+
+initializeAboutPageTheme(pinia);
+createApp(AboutPage).use(pinia).mount('#about-page');
diff --git a/src/presentation/about-page/theme-bootstrap.js b/src/presentation/about-page/theme-bootstrap.js
new file mode 100644
index 0000000..534fdd7
--- /dev/null
+++ b/src/presentation/about-page/theme-bootstrap.js
@@ -0,0 +1,23 @@
+import { calculateSchemeColors } from '../../domain/scheme/color-scheme-calculator';
+import { SCHEME_STORAGE_KEY } from '../../infrastructure/browser/scheme-storage-key';
+import { readSchemeFromSearch } from '../../infrastructure/url/scheme-query';
+import { useCalculatedSchemeStore } from '../shared/stores/calculated-scheme';
+import { useSchemeStore } from '../shared/stores/scheme';
+import { applyThemeVariables } from '../shared/theme/css-theme-variables';
+
+function readPersistedSchemeSearch() {
+ try {
+ return window.localStorage.getItem(SCHEME_STORAGE_KEY) || '';
+ } catch {
+ return '';
+ }
+}
+
+export function initializeAboutPageTheme(pinia) {
+ const scheme = readSchemeFromSearch(readPersistedSchemeSearch());
+ const colors = calculateSchemeColors(scheme);
+
+ useSchemeStore(pinia).replaceScheme(scheme);
+ useCalculatedSchemeStore(pinia).calculatedScheme = colors;
+ applyThemeVariables(colors);
+}
diff --git a/src/presentation/editor-page/EditorPage.vue b/src/presentation/editor-page/EditorPage.vue
new file mode 100644
index 0000000..67a95bf
--- /dev/null
+++ b/src/presentation/editor-page/EditorPage.vue
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/presentation/editor-page/calculated-scheme-sync.js b/src/presentation/editor-page/calculated-scheme-sync.js
new file mode 100644
index 0000000..259621e
--- /dev/null
+++ b/src/presentation/editor-page/calculated-scheme-sync.js
@@ -0,0 +1,43 @@
+import { watch } from 'vue';
+import { useSchemeStore } from '../shared/stores/scheme';
+import { useCalculatedSchemeStore } from '../shared/stores/calculated-scheme';
+import { calculateSchemeColors } from '../../domain/scheme/color-scheme-calculator';
+
+class CalculatedSchemeSync {
+ constructor({
+ schemeStore = useSchemeStore(),
+ calculatedSchemeStore = useCalculatedSchemeStore(),
+ } = {}) {
+ this.schemeStore = schemeStore;
+ this.calculatedSchemeStore = calculatedSchemeStore;
+ this.stopWatcher = null;
+
+ this.start(watch);
+ }
+
+ updateCalculatedScheme(scheme) {
+ this.calculatedSchemeStore.calculatedScheme = calculateSchemeColors(scheme);
+ }
+
+ start(watchFn = watch) {
+ this.stop();
+ this.stopWatcher = watchFn(
+ () => this.schemeStore.scheme,
+ (scheme) => {
+ if (scheme) {
+ this.updateCalculatedScheme(scheme);
+ }
+ },
+ { immediate: true, deep: true }
+ );
+
+ return this.stopWatcher;
+ }
+
+ stop() {
+ this.stopWatcher?.();
+ this.stopWatcher = null;
+ }
+}
+
+export default CalculatedSchemeSync;
diff --git a/src/presentation/editor-page/components/EditorControls.vue b/src/presentation/editor-page/components/EditorControls.vue
new file mode 100644
index 0000000..dd647ff
--- /dev/null
+++ b/src/presentation/editor-page/components/EditorControls.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
Global Properties
+ defaults
+
+ Hue:
+
+ Saturation:
+
+
+
+ Lightness
+ Color:
+
+ Black:
+
+ White:
+
+
+
+
+
+
+
+
+
diff --git a/src/presentation/editor-page/components/TerminalDisplay.vue b/src/presentation/editor-page/components/TerminalDisplay.vue
new file mode 100644
index 0000000..fcaded3
--- /dev/null
+++ b/src/presentation/editor-page/components/TerminalDisplay.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
diff --git a/src/presentation/editor-page/components/controls/AdvancedControls.vue b/src/presentation/editor-page/components/controls/AdvancedControls.vue
new file mode 100644
index 0000000..74928f0
--- /dev/null
+++ b/src/presentation/editor-page/components/controls/AdvancedControls.vue
@@ -0,0 +1,428 @@
+
+
+ Advanced
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/presentation/editor-page/components/controls/advanced/AdvancedOptionGroup.vue b/src/presentation/editor-page/components/controls/advanced/AdvancedOptionGroup.vue
new file mode 100644
index 0000000..92bdf92
--- /dev/null
+++ b/src/presentation/editor-page/components/controls/advanced/AdvancedOptionGroup.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
diff --git a/src/presentation/editor-page/components/controls/advanced/LegacyColorPicker.vue b/src/presentation/editor-page/components/controls/advanced/LegacyColorPicker.vue
new file mode 100644
index 0000000..90fb5ae
--- /dev/null
+++ b/src/presentation/editor-page/components/controls/advanced/LegacyColorPicker.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/presentation/editor-page/components/controls/sliders/BaseSlider.vue b/src/presentation/editor-page/components/controls/sliders/BaseSlider.vue
new file mode 100644
index 0000000..c40dd4b
--- /dev/null
+++ b/src/presentation/editor-page/components/controls/sliders/BaseSlider.vue
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
diff --git a/src/presentation/editor-page/components/controls/sliders/BlackLightnessSlider.vue b/src/presentation/editor-page/components/controls/sliders/BlackLightnessSlider.vue
new file mode 100644
index 0000000..ab0a1e5
--- /dev/null
+++ b/src/presentation/editor-page/components/controls/sliders/BlackLightnessSlider.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
diff --git a/src/presentation/editor-page/components/controls/sliders/ColorLightnessSlider.vue b/src/presentation/editor-page/components/controls/sliders/ColorLightnessSlider.vue
new file mode 100644
index 0000000..3859a0b
--- /dev/null
+++ b/src/presentation/editor-page/components/controls/sliders/ColorLightnessSlider.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
diff --git a/src/presentation/editor-page/components/controls/sliders/HueSlider.vue b/src/presentation/editor-page/components/controls/sliders/HueSlider.vue
new file mode 100644
index 0000000..a5f35f5
--- /dev/null
+++ b/src/presentation/editor-page/components/controls/sliders/HueSlider.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
diff --git a/src/presentation/editor-page/components/controls/sliders/SaturationSlider.vue b/src/presentation/editor-page/components/controls/sliders/SaturationSlider.vue
new file mode 100644
index 0000000..bbc2a63
--- /dev/null
+++ b/src/presentation/editor-page/components/controls/sliders/SaturationSlider.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
diff --git a/src/presentation/editor-page/components/controls/sliders/WhiteLightnessSlider.vue b/src/presentation/editor-page/components/controls/sliders/WhiteLightnessSlider.vue
new file mode 100644
index 0000000..1489bb6
--- /dev/null
+++ b/src/presentation/editor-page/components/controls/sliders/WhiteLightnessSlider.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
diff --git a/src/presentation/editor-page/components/page-header/DownloadSchemeDialog.vue b/src/presentation/editor-page/components/page-header/DownloadSchemeDialog.vue
new file mode 100644
index 0000000..07f5129
--- /dev/null
+++ b/src/presentation/editor-page/components/page-header/DownloadSchemeDialog.vue
@@ -0,0 +1,276 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/presentation/editor-page/components/page-header/GetSchemeButton.vue b/src/presentation/editor-page/components/page-header/GetSchemeButton.vue
new file mode 100644
index 0000000..fe23b41
--- /dev/null
+++ b/src/presentation/editor-page/components/page-header/GetSchemeButton.vue
@@ -0,0 +1,35 @@
+
+
+ Download Scheme
+
+
+
+
+
diff --git a/src/presentation/editor-page/components/page-header/SocialMedia.vue b/src/presentation/editor-page/components/page-header/SocialMedia.vue
new file mode 100644
index 0000000..5adc9b1
--- /dev/null
+++ b/src/presentation/editor-page/components/page-header/SocialMedia.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
diff --git a/src/presentation/editor-page/main.js b/src/presentation/editor-page/main.js
new file mode 100644
index 0000000..47e279b
--- /dev/null
+++ b/src/presentation/editor-page/main.js
@@ -0,0 +1,34 @@
+import '../shared/assets/styles/jquery-ui.custom.css'
+import '../shared/assets/styles/jquery.ui.colorPicker.css'
+import '../shared/assets/styles/reset.less';
+import '../shared/assets/styles/fonts.less';
+import '../shared/assets/styles/base.less';
+import '../shared/assets/styles/page-shell.less';
+
+import jQuery from 'jquery'
+window.jQuery = window.$ = jQuery
+
+import { createPinia } from 'pinia';
+import { createApp } from 'vue';
+import EditorPage from './EditorPage.vue';
+import { hydrateSchemeStoreFromLocation } from '../../infrastructure/browser/scheme-url-sync';
+import { useSchemeStore } from '../shared/stores/scheme';
+
+async function startApp() {
+ await import('jquery-ui/ui/version');
+ await import('jquery-ui/ui/widget');
+ await import('jquery-ui/ui/data');
+ await import('jquery-ui/ui/plugin');
+ await import('jquery-ui/ui/scroll-parent');
+ await import('jquery-ui/ui/keycode');
+ await import('jquery-ui/ui/widgets/mouse');
+ await import('jquery-ui/ui/widgets/draggable');
+ await import('jquery-ui/ui/widgets/slider');
+ await import('../../infrastructure/vendor/jquery.ui.colorPicker.js');
+
+ const pinia = createPinia();
+ hydrateSchemeStoreFromLocation(useSchemeStore(pinia));
+ createApp(EditorPage).use(pinia).mount('#app');
+}
+
+startApp();
diff --git a/src/presentation/editor-page/terminal-preview/terminal-view-theme.js b/src/presentation/editor-page/terminal-preview/terminal-view-theme.js
new file mode 100644
index 0000000..73f06b2
--- /dev/null
+++ b/src/presentation/editor-page/terminal-preview/terminal-view-theme.js
@@ -0,0 +1,28 @@
+function colorHex(colors, name) {
+ return colors[name]?.hex?.() ?? '#000000';
+}
+
+export function terminalViewThemeFromScheme(colors) {
+ return {
+ background: colorHex(colors, 'background'),
+ foreground: colorHex(colors, 'foreground'),
+ cursor: colorHex(colors, 'foreground'),
+ selectionBackground: colorHex(colors, 'brightBlack'),
+ black: colorHex(colors, 'black'),
+ red: colorHex(colors, 'red'),
+ green: colorHex(colors, 'green'),
+ yellow: colorHex(colors, 'yellow'),
+ blue: colorHex(colors, 'blue'),
+ magenta: colorHex(colors, 'magenta'),
+ cyan: colorHex(colors, 'cyan'),
+ white: colorHex(colors, 'white'),
+ brightBlack: colorHex(colors, 'brightBlack'),
+ brightRed: colorHex(colors, 'brightRed'),
+ brightGreen: colorHex(colors, 'brightGreen'),
+ brightYellow: colorHex(colors, 'brightYellow'),
+ brightBlue: colorHex(colors, 'brightBlue'),
+ brightMagenta: colorHex(colors, 'brightMagenta'),
+ brightCyan: colorHex(colors, 'brightCyan'),
+ brightWhite: colorHex(colors, 'brightWhite'),
+ };
+}
diff --git a/src/presentation/editor-page/terminal-preview/terminal-view.js b/src/presentation/editor-page/terminal-preview/terminal-view.js
new file mode 100644
index 0000000..6b95c05
--- /dev/null
+++ b/src/presentation/editor-page/terminal-preview/terminal-view.js
@@ -0,0 +1,220 @@
+import { Terminal } from '@xterm/xterm';
+import '@xterm/xterm/css/xterm.css';
+
+const TERMINAL_COLUMNS = 80;
+const TERMINAL_ROWS = 25;
+const TERMINAL_SCROLLBACK_ROWS = 1000;
+const TERMINAL_FONT_SIZE = 20;
+const TERMINAL_FONT_FAMILY = 'Inconsolata, monospace';
+const TERMINAL_FONT_LOAD = `${TERMINAL_FONT_SIZE}px Inconsolata`;
+const BACKSPACE = '\x7F';
+const CARRIAGE_RETURN = '\r';
+const LINE_BREAK = '\r\n';
+const CLEAR_FROM_CURSOR = '\x1b[J';
+
+function waitForTerminalFont() {
+ if (!document.fonts?.load) {
+ return Promise.resolve();
+ }
+
+ return document.fonts.load(TERMINAL_FONT_LOAD).then(() => document.fonts.ready);
+}
+
+export function createTerminalView(container, options = {}, dependencies = {}) {
+ const TerminalClass = dependencies.TerminalClass ?? Terminal;
+ const waitForFont = dependencies.waitForFont ?? waitForTerminalFont;
+ let terminal = null;
+ let disposed = false;
+ let lastSequence = '';
+ let renderedSequence = '';
+ let lastTheme = options.theme;
+ let currentInput = '';
+ let activeDynamicCommand = null;
+ let activeDynamicLineCount = 0;
+ let dataDisposable = null;
+
+ function prompt() {
+ return options.prompt ?? '';
+ }
+
+ function runCommand(command) {
+ return options.runCommand?.(command) ?? '';
+ }
+
+ function writePrompt() {
+ terminal.write(prompt());
+ }
+
+ function dynamicLineCount(output) {
+ return output.content.split(LINE_BREAK).length;
+ }
+
+ function writeCommandOutput(output) {
+ terminal.write(output.content);
+ terminal.write(LINE_BREAK);
+ terminal.write(LINE_BREAK);
+ writePrompt();
+ }
+
+ function writeDynamicCommand(output) {
+ activeDynamicLineCount = dynamicLineCount(output);
+ writeCommandOutput(output);
+ }
+
+ function rewriteDynamicCommand(output) {
+ const linesToOutputStart = activeDynamicLineCount + 1;
+ terminal.write(`\x1b[${linesToOutputStart}A\r${CLEAR_FROM_CURSOR}`);
+ writeDynamicCommand(output);
+ }
+
+ function createTerminal() {
+ return new TerminalClass({
+ allowProposedApi: false,
+ cols: TERMINAL_COLUMNS,
+ rows: TERMINAL_ROWS,
+ convertEol: true,
+ cursorBlink: true,
+ cursorStyle: 'block',
+ disableStdin: false,
+ fontFamily: TERMINAL_FONT_FAMILY,
+ fontSize: TERMINAL_FONT_SIZE,
+ fontWeight: 'normal',
+ fontWeightBold: 'bold',
+ lineHeight: 1,
+ letterSpacing: -0.5,
+ screenReaderMode: true,
+ scrollback: TERMINAL_SCROLLBACK_ROWS,
+ theme: lastTheme,
+ });
+ }
+
+ function openTerminal() {
+ if (disposed || terminal) {
+ return;
+ }
+
+ terminal = createTerminal();
+ terminal.open(container);
+ terminal.focus?.();
+ dataDisposable = terminal.onData?.(handleData);
+
+ if (lastSequence) {
+ render(lastSequence, lastTheme);
+ }
+ }
+
+ waitForFont().then(openTerminal);
+
+ function handleBackspace() {
+ if (currentInput.length === 0) {
+ return;
+ }
+
+ currentInput = currentInput.slice(0, -1);
+ terminal.write('\b \b');
+ }
+
+ function handleEnter() {
+ const command = currentInput;
+ const output = runCommand(command);
+ currentInput = '';
+
+ terminal.write(LINE_BREAK);
+
+ if (output?.type === 'clear') {
+ activeDynamicCommand = null;
+ activeDynamicLineCount = 0;
+ renderedSequence = lastSequence;
+ terminal.reset();
+ writePrompt();
+ return;
+ }
+
+ if (output?.type === 'dynamic') {
+ activeDynamicCommand = command;
+ writeDynamicCommand(output);
+ return;
+ }
+
+ activeDynamicCommand = null;
+ activeDynamicLineCount = 0;
+
+ if (output) {
+ writeCommandOutput({ content: output });
+ return;
+ }
+
+ writePrompt();
+ }
+
+ function handleData(data) {
+ if (!terminal) {
+ return;
+ }
+
+ if (data === CARRIAGE_RETURN) {
+ handleEnter();
+ return;
+ }
+
+ if (data === BACKSPACE) {
+ handleBackspace();
+ return;
+ }
+
+ if (/^[\x20-\x7E]+$/.test(data)) {
+ activeDynamicCommand = null;
+ activeDynamicLineCount = 0;
+ currentInput += data;
+ terminal.write(data);
+ }
+ }
+
+ function render(sequence, theme) {
+ lastSequence = sequence;
+ lastTheme = theme;
+
+ if (!terminal) {
+ return;
+ }
+
+ terminal.options.theme = theme;
+
+ if (sequence === renderedSequence) {
+ return;
+ }
+
+ renderedSequence = sequence;
+ terminal.reset();
+ currentInput = '';
+ activeDynamicCommand = null;
+ activeDynamicLineCount = 0;
+ terminal.write(sequence);
+ }
+
+ function refreshDynamicCommand() {
+ if (!terminal || !activeDynamicCommand) {
+ return;
+ }
+
+ const output = runCommand(activeDynamicCommand);
+
+ if (output?.type !== 'dynamic') {
+ activeDynamicCommand = null;
+ activeDynamicLineCount = 0;
+ return;
+ }
+
+ rewriteDynamicCommand(output);
+ }
+
+ return {
+ render,
+ refreshDynamicCommand,
+ dispose() {
+ disposed = true;
+ dataDisposable?.dispose();
+ terminal?.dispose();
+ },
+ };
+}
diff --git a/fonts/inconsolata/ttf-inconsolata-webfont.eot b/src/presentation/shared/assets/fonts/inconsolata/ttf-inconsolata-webfont.eot
similarity index 100%
rename from fonts/inconsolata/ttf-inconsolata-webfont.eot
rename to src/presentation/shared/assets/fonts/inconsolata/ttf-inconsolata-webfont.eot
diff --git a/fonts/inconsolata/ttf-inconsolata-webfont.svg b/src/presentation/shared/assets/fonts/inconsolata/ttf-inconsolata-webfont.svg
similarity index 100%
rename from fonts/inconsolata/ttf-inconsolata-webfont.svg
rename to src/presentation/shared/assets/fonts/inconsolata/ttf-inconsolata-webfont.svg
diff --git a/fonts/inconsolata/ttf-inconsolata-webfont.ttf b/src/presentation/shared/assets/fonts/inconsolata/ttf-inconsolata-webfont.ttf
similarity index 100%
rename from fonts/inconsolata/ttf-inconsolata-webfont.ttf
rename to src/presentation/shared/assets/fonts/inconsolata/ttf-inconsolata-webfont.ttf
diff --git a/fonts/inconsolata/ttf-inconsolata-webfont.woff b/src/presentation/shared/assets/fonts/inconsolata/ttf-inconsolata-webfont.woff
similarity index 100%
rename from fonts/inconsolata/ttf-inconsolata-webfont.woff
rename to src/presentation/shared/assets/fonts/inconsolata/ttf-inconsolata-webfont.woff
diff --git a/fonts/rationale/rationale-webfont.eot b/src/presentation/shared/assets/fonts/rationale/rationale-webfont.eot
similarity index 100%
rename from fonts/rationale/rationale-webfont.eot
rename to src/presentation/shared/assets/fonts/rationale/rationale-webfont.eot
diff --git a/fonts/rationale/rationale-webfont.svg b/src/presentation/shared/assets/fonts/rationale/rationale-webfont.svg
similarity index 100%
rename from fonts/rationale/rationale-webfont.svg
rename to src/presentation/shared/assets/fonts/rationale/rationale-webfont.svg
diff --git a/fonts/rationale/rationale-webfont.ttf b/src/presentation/shared/assets/fonts/rationale/rationale-webfont.ttf
similarity index 100%
rename from fonts/rationale/rationale-webfont.ttf
rename to src/presentation/shared/assets/fonts/rationale/rationale-webfont.ttf
diff --git a/fonts/rationale/rationale-webfont.woff b/src/presentation/shared/assets/fonts/rationale/rationale-webfont.woff
similarity index 100%
rename from fonts/rationale/rationale-webfont.woff
rename to src/presentation/shared/assets/fonts/rationale/rationale-webfont.woff
diff --git a/src/presentation/shared/assets/styles/base.less b/src/presentation/shared/assets/styles/base.less
new file mode 100644
index 0000000..a6d2487
--- /dev/null
+++ b/src/presentation/shared/assets/styles/base.less
@@ -0,0 +1,23 @@
+a {
+ color: #000;
+ text-decoration: none;
+}
+
+a,
+a:link,
+a:visited,
+a:hover,
+a:active,
+a *,
+a:link *,
+a:visited *,
+a:hover *,
+a:active * {
+ cursor: pointer !important;
+}
+
+.ui-tabs .ui-tabs-nav li.ui-tabs-selected a,
+.ui-tabs .ui-tabs-nav li.ui-state-disabled a,
+.ui-tabs .ui-tabs-nav li.ui-state-processing a {
+ cursor: text !important;
+}
diff --git a/src/presentation/shared/assets/styles/fonts.less b/src/presentation/shared/assets/styles/fonts.less
new file mode 100644
index 0000000..7e3026f
--- /dev/null
+++ b/src/presentation/shared/assets/styles/fonts.less
@@ -0,0 +1,23 @@
+@font-face {
+ font-family: Rationale;
+ src: url('@/presentation/shared/assets/fonts/rationale/rationale-webfont.eot');
+ src:
+ url('@/presentation/shared/assets/fonts/rationale/rationale-webfont.eot?#iefix') format('embedded-opentype'),
+ url('@/presentation/shared/assets/fonts/rationale/rationale-webfont.woff') format('woff'),
+ url('@/presentation/shared/assets/fonts/rationale/rationale-webfont.ttf') format('truetype'),
+ url('@/presentation/shared/assets/fonts/rationale/rationale-webfont.svg#webfontregular') format('svg');
+ font-weight: normal;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: Inconsolata;
+ src: url('@/presentation/shared/assets/fonts/inconsolata/ttf-inconsolata-webfont.eot');
+ src:
+ url('@/presentation/shared/assets/fonts/inconsolata/ttf-inconsolata-webfont.eot?#iefix') format('embedded-opentype'),
+ url('@/presentation/shared/assets/fonts/inconsolata/ttf-inconsolata-webfont.woff') format('woff'),
+ url('@/presentation/shared/assets/fonts/inconsolata/ttf-inconsolata-webfont.ttf') format('truetype'),
+ url('@/presentation/shared/assets/fonts/inconsolata/ttf-inconsolata-webfont.svg#webfontregular') format('svg');
+ font-weight: normal;
+ font-style: normal;
+}
diff --git a/css/jquery-ui-1.8.23.custom.css b/src/presentation/shared/assets/styles/jquery-ui.custom.css
old mode 100755
new mode 100644
similarity index 94%
rename from css/jquery-ui-1.8.23.custom.css
rename to src/presentation/shared/assets/styles/jquery-ui.custom.css
index d25942d..881eefe
--- a/css/jquery-ui-1.8.23.custom.css
+++ b/src/presentation/shared/assets/styles/jquery-ui.custom.css
@@ -56,26 +56,26 @@
.ui-widget { font-family: Trebuchet MS, Helvetica, Arial, sans-serif; font-size: 1.1em; }
.ui-widget .ui-widget { font-size: 1em; }
.ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button { font-family: Trebuchet MS, Helvetica, Arial, sans-serif; font-size: 1em; }
-.ui-widget-content { border: 1px solid #aaaaaa; background: #c9c9c9 url(images/ui-bg_inset-soft_50_c9c9c9_1x100.png) 50% bottom repeat-x; color: #333333; }
+.ui-widget-content { border: 1px solid #aaaaaa; background: #c9c9c9 url(/images/ui-bg_inset-soft_50_c9c9c9_1x100.png) 50% bottom repeat-x; color: #333333; }
.ui-widget-content a { color: #333333; }
-.ui-widget-header { border: 1px solid #bbbbbb; background: #dddddd url(images/ui-bg_glass_35_dddddd_1x400.png) 50% 50% repeat-x; color: #444444; font-weight: bold; }
+.ui-widget-header { border: 1px solid #bbbbbb; background: #dddddd url(/images/ui-bg_glass_35_dddddd_1x400.png) 50% 50% repeat-x; color: #444444; font-weight: bold; }
.ui-widget-header a { color: #444444; }
/* Interaction states
----------------------------------*/
-.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #cccccc; background: #eeeeee url(images/ui-bg_glass_60_eeeeee_1x400.png) 50% 50% repeat-x; font-weight: bold; color: #3383bb; }
+.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #cccccc; background: #eeeeee url(/images/ui-bg_glass_60_eeeeee_1x400.png) 50% 50% repeat-x; font-weight: bold; color: #3383bb; }
.ui-state-default a, .ui-state-default a:link, .ui-state-default a:visited { color: #3383bb; text-decoration: none; }
-.ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { border: 1px solid #bbbbbb; background: #f8f8f8 url(images/ui-bg_glass_100_f8f8f8_1x400.png) 50% 50% repeat-x; font-weight: bold; color: #599fcf; }
+.ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { border: 1px solid #bbbbbb; background: #f8f8f8 url(/images/ui-bg_glass_100_f8f8f8_1x400.png) 50% 50% repeat-x; font-weight: bold; color: #599fcf; }
.ui-state-hover a, .ui-state-hover a:hover { color: #599fcf; text-decoration: none; }
-.ui-state-active, .ui-widget-content .ui-state-active, .ui-widget-header .ui-state-active { border: 1px solid #999999; background: #999999 url(images/ui-bg_inset-hard_75_999999_1x100.png) 50% 50% repeat-x; font-weight: bold; color: #ffffff; }
+.ui-state-active, .ui-widget-content .ui-state-active, .ui-widget-header .ui-state-active { border: 1px solid #999999; background: #999999 url(/images/ui-bg_inset-hard_75_999999_1x100.png) 50% 50% repeat-x; font-weight: bold; color: #ffffff; }
.ui-state-active a, .ui-state-active a:link, .ui-state-active a:visited { color: #ffffff; text-decoration: none; }
.ui-widget :active { outline: none; }
/* Interaction Cues
----------------------------------*/
-.ui-state-highlight, .ui-widget-content .ui-state-highlight, .ui-widget-header .ui-state-highlight {border: 1px solid #ffffff; background: #eeeeee url(images/ui-bg_flat_55_eeeeee_40x100.png) 50% 50% repeat-x; color: #444444; }
+.ui-state-highlight, .ui-widget-content .ui-state-highlight, .ui-widget-header .ui-state-highlight {border: 1px solid #ffffff; background: #eeeeee; color: #444444; }
.ui-state-highlight a, .ui-widget-content .ui-state-highlight a,.ui-widget-header .ui-state-highlight a { color: #444444; }
-.ui-state-error, .ui-widget-content .ui-state-error, .ui-widget-header .ui-state-error {border: 1px solid #c0402a; background: #c0402a url(images/ui-bg_flat_55_c0402a_40x100.png) 50% 50% repeat-x; color: #ffffff; }
+.ui-state-error, .ui-widget-content .ui-state-error, .ui-widget-header .ui-state-error {border: 1px solid #c0402a; background: #c0402a; color: #ffffff; }
.ui-state-error a, .ui-widget-content .ui-state-error a, .ui-widget-header .ui-state-error a { color: #ffffff; }
.ui-state-error-text, .ui-widget-content .ui-state-error-text, .ui-widget-header .ui-state-error-text { color: #ffffff; }
.ui-priority-primary, .ui-widget-content .ui-priority-primary, .ui-widget-header .ui-priority-primary { font-weight: bold; }
@@ -86,14 +86,14 @@
----------------------------------*/
/* states and images */
-.ui-icon { width: 16px; height: 16px; background-image: url(images/ui-icons_999999_256x240.png); }
-.ui-widget-content .ui-icon {background-image: url(images/ui-icons_999999_256x240.png); }
-.ui-widget-header .ui-icon {background-image: url(images/ui-icons_999999_256x240.png); }
-.ui-state-default .ui-icon { background-image: url(images/ui-icons_70b2e1_256x240.png); }
-.ui-state-hover .ui-icon, .ui-state-focus .ui-icon {background-image: url(images/ui-icons_3383bb_256x240.png); }
-.ui-state-active .ui-icon {background-image: url(images/ui-icons_454545_256x240.png); }
-.ui-state-highlight .ui-icon {background-image: url(images/ui-icons_3383bb_256x240.png); }
-.ui-state-error .ui-icon, .ui-state-error-text .ui-icon {background-image: url(images/ui-icons_fbc856_256x240.png); }
+.ui-icon { width: 16px; height: 16px; background-image: url(/images/ui-icons_999999_256x240.png); }
+.ui-widget-content .ui-icon {background-image: url(/images/ui-icons_999999_256x240.png); }
+.ui-widget-header .ui-icon {background-image: url(/images/ui-icons_999999_256x240.png); }
+.ui-state-default .ui-icon { background-image: url(/images/ui-icons_70b2e1_256x240.png); }
+.ui-state-hover .ui-icon, .ui-state-focus .ui-icon {background-image: url(/images/ui-icons_3383bb_256x240.png); }
+.ui-state-active .ui-icon {background-image: url(/images/ui-icons_454545_256x240.png); }
+.ui-state-highlight .ui-icon {background-image: url(/images/ui-icons_3383bb_256x240.png); }
+.ui-state-error .ui-icon, .ui-state-error-text .ui-icon {background-image: url(/images/ui-icons_fbc856_256x240.png); }
/* positioning */
.ui-icon-carat-1-n { background-position: 0 0; }
@@ -283,8 +283,8 @@
.ui-corner-all, .ui-corner-bottom, .ui-corner-right, .ui-corner-br { -moz-border-radius-bottomright: 6px; -webkit-border-bottom-right-radius: 6px; -khtml-border-bottom-right-radius: 6px; border-bottom-right-radius: 6px; }
/* Overlays */
-.ui-widget-overlay { background: #eeeeee url(images/ui-bg_flat_0_eeeeee_40x100.png) 50% 50% repeat-x; opacity: .80;filter:Alpha(Opacity=80); }
-.ui-widget-shadow { margin: -4px 0 0 -4px; padding: 4px; background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .60;filter:Alpha(Opacity=60); -moz-border-radius: 0px; -khtml-border-radius: 0px; -webkit-border-radius: 0px; border-radius: 0px; }/*!
+.ui-widget-overlay { background: #eeeeee url(/images/ui-bg_flat_0_eeeeee_40x100.png) 50% 50% repeat-x; opacity: .80;filter:Alpha(Opacity=80); }
+.ui-widget-shadow { margin: -4px 0 0 -4px; padding: 4px; background: #aaaaaa url(/images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .60;filter:Alpha(Opacity=60); -moz-border-radius: 0px; -khtml-border-radius: 0px; -webkit-border-radius: 0px; border-radius: 0px; }/*!
* jQuery UI Resizable 1.8.23
*
* Copyright 2012, AUTHORS.txt (http://jqueryui.com/about)
@@ -560,4 +560,4 @@ button.ui-button::-moz-focus-inner { border: 0; padding: 0; } /* reset extra pad
* http://docs.jquery.com/UI/Progressbar#theming
*/
.ui-progressbar { height:2em; text-align: left; overflow: hidden; }
-.ui-progressbar .ui-progressbar-value {margin: -1px; height:100%; }
\ No newline at end of file
+.ui-progressbar .ui-progressbar-value {margin: -1px; height:100%; }
diff --git a/src/presentation/shared/assets/styles/jquery.ui.colorPicker.css b/src/presentation/shared/assets/styles/jquery.ui.colorPicker.css
new file mode 100644
index 0000000..ef80716
--- /dev/null
+++ b/src/presentation/shared/assets/styles/jquery.ui.colorPicker.css
@@ -0,0 +1,76 @@
+div.colorpicker {
+ margin: 0;
+ position: relative;
+}
+
+div.colorpicker canvas {
+ float: left;
+}
+
+div.colorpicker div.circle {
+ border-radius: 100%;
+ position: absolute;
+ top: 0;
+ background-clip: padding-box;
+}
+
+div.colorpicker div.circle.alpha {
+ background-image: url('/images/transparent.gif');
+ opacity: 0;
+}
+
+div.colorpicker div.circle.lightness {
+ background-color: rgb(0 0 0 / 0%);
+ box-shadow: 0 1px 3px rgb(0 0 0 / 20%);
+ border: 1px solid #fff;
+ margin: -1px 0 0 -1px;
+}
+
+div.colorpicker span.picker,
+div.colorpicker span.handle {
+ display: block;
+ width: 8px;
+ height: 8px;
+ border-radius: 4px;
+ background-color: rgb(0 0 0 / 40%);
+ box-shadow: 0 1px 3px rgb(0 0 0 / 50%), 0 0 4px #000 inset;
+ border: 1px solid #fff;
+ cursor: pointer;
+ margin: -5px;
+ position: absolute;
+ z-index: 2;
+}
+
+div.colorpicker div.slider {
+ width: 6px;
+ background-color: #fff;
+ background-clip: padding-box;
+ border-radius: 6px;
+ border: 1px solid #fff;
+ box-shadow: 0 1px 3px rgb(0 0 0 / 15%), 0 1px 2px rgb(0 0 0 / 20%) inset;
+ float: left;
+ margin: -1px 0 0 10px;
+}
+
+div.colorpicker div.slider span.handle {
+ margin: -5px -2px;
+}
+
+div.colorpicker div.slider.lightness {
+ background-image: linear-gradient(to bottom, rgb(0 0 0 / 0%), rgb(0 0 0 / 100%));
+}
+
+div.colorpicker div.slider.alpha {
+ background:
+ linear-gradient(to bottom, rgb(255 255 255 / 100%), rgb(255 255 255 / 60%)),
+ url('/images/transparent.gif') center;
+}
+
+div.colorpicker input.colorInput {
+ margin-top: 10px;
+ border: 1px solid #fff;
+ box-shadow: 0 1px 3px rgb(0 0 0 / 15%);
+ background-color: #fafafa;
+ border-radius: 3px;
+ padding: 5px 8px;
+}
diff --git a/src/presentation/shared/assets/styles/page-shell.less b/src/presentation/shared/assets/styles/page-shell.less
new file mode 100644
index 0000000..993c0f3
--- /dev/null
+++ b/src/presentation/shared/assets/styles/page-shell.less
@@ -0,0 +1,229 @@
+@app_width: 1190px;
+@header_height: 60px;
+@footer_height: 40px;
+
+:root {
+ color-scheme: light;
+ --page: #eeeeee;
+ --paper: #fafafa;
+ --ink: #252525;
+ --muted: #606060;
+ --line: #cfcfcf;
+ --color-blue: #3383bb;
+ --color-cyan: #70b2e1;
+ --color-magenta: #b767b7;
+ --color-red: #cc6666;
+}
+
+a {
+ color: #000;
+ text-decoration: none;
+}
+
+a,
+a:link,
+a:visited,
+a:hover,
+a:active,
+a *,
+a:link *,
+a:visited *,
+a:hover *,
+a:active * {
+ cursor: pointer !important;
+}
+
+body {
+ background-color: #eee;
+ font-family: Rationale, sans-serif;
+}
+
+.wrapper {
+ margin: 0 auto (-@footer_height - 2px);
+}
+
+header {
+ position: relative;
+ min-width: @app_width;
+ height: @header_height;
+ overflow: visible;
+}
+
+#logo {
+ color: #777;
+ font-size: 48px;
+ display: inline-block;
+ margin: 18px 0 0 20px;
+
+ a {
+ color: #777;
+ }
+}
+
+.blue {
+ color: var(--color-blue);
+}
+
+.cyan {
+ color: var(--color-cyan);
+}
+
+.magenta {
+ color: var(--color-magenta);
+}
+
+.red {
+ color: var(--color-red);
+}
+
+#skews {
+ position: absolute;
+ top: 23px;
+ right: 27px;
+ height: 34px;
+ font-size: 0;
+}
+
+.skew {
+ box-sizing: content-box;
+ vertical-align: top;
+ display: inline-block;
+ height: 34px;
+ transform: skew(-30deg);
+ border-radius: 6px;
+ margin-left: 7px;
+ margin-top: 0;
+}
+
+.skew > * {
+ transform: skew(30deg);
+}
+
+#get-scheme-button {
+ width: 163px;
+}
+
+#social-media {
+ border: 1px solid #aaa;
+ background-color: #c9c9c9;
+ position: relative;
+ width: 180px;
+ display: inline-block;
+ white-space: nowrap;
+
+ .buttons {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ }
+
+ .inner {
+ position: absolute;
+ top: 8px;
+ left: 16px;
+ }
+}
+
+.share-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 18px;
+ border: 0;
+ border-radius: 999px;
+ color: #fff;
+ font-family: Arial, Verdana, sans-serif;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: bold;
+ line-height: 1;
+ text-decoration: none;
+ text-transform: none;
+ transition: background-color 120ms ease;
+}
+
+.share-button:focus-visible {
+ outline: 1px dotted #111;
+ outline-offset: 2px;
+}
+
+.share-button--x {
+ background: #111;
+}
+
+.share-button--x:hover {
+ background: #2b2b2b;
+}
+
+.share-button--linkedin {
+ background: #0a66c2;
+}
+
+.share-button--linkedin:hover {
+ background: #0d76de;
+}
+
+.share-button--facebook {
+ background: #1877f2;
+}
+
+.share-button--facebook:hover {
+ background: #2d86f3;
+}
+
+#footer {
+ min-width: @app_width;
+ height: @footer_height;
+ overflow: visible;
+ font-size: 20px;
+ width: 100%;
+
+ p {
+ display: inline-block;
+ }
+
+ .left {
+ float: left;
+ margin-left: 20px;
+ padding-bottom: 20px;
+
+ img {
+ width: 20px;
+ height: 20px;
+ margin: 0 0 -2px;
+ }
+ }
+
+ .right {
+ float: right;
+ margin-right: 20px;
+ padding-bottom: 20px;
+ }
+
+ .left,
+ .right {
+ opacity: 0.5;
+ }
+
+ .left:hover,
+ .right:hover {
+ opacity: 1;
+ }
+}
+
+#footer p:hover .footer-link {
+ color: var(--color-blue);
+}
+
+#footer p:hover .footer-link:visited {
+ color: var(--color-magenta);
+}
+
+.twitter-follow-button,
+.twitter-follow-button-rendered {
+ display: inline-block;
+ margin-bottom: -4px;
+ font-size: 0;
+ width: 74px;
+}
diff --git a/src/presentation/shared/assets/styles/reset.less b/src/presentation/shared/assets/styles/reset.less
new file mode 100644
index 0000000..80b93ea
--- /dev/null
+++ b/src/presentation/shared/assets/styles/reset.less
@@ -0,0 +1,134 @@
+/* http://meyerweb.com/eric/tools/css/reset/
+ v2.0 | 20110126
+ License: none (public domain)
+*/
+
+html,
+body,
+div,
+span,
+applet,
+object,
+iframe,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+blockquote,
+pre,
+a,
+abbr,
+acronym,
+address,
+big,
+cite,
+code,
+del,
+dfn,
+em,
+img,
+ins,
+kbd,
+q,
+s,
+samp,
+small,
+strike,
+strong,
+sub,
+sup,
+tt,
+var,
+b,
+u,
+i,
+center,
+dl,
+dt,
+dd,
+ol,
+ul,
+li,
+fieldset,
+form,
+label,
+legend,
+table,
+caption,
+tbody,
+tfoot,
+thead,
+tr,
+th,
+td,
+article,
+aside,
+canvas,
+details,
+embed,
+figure,
+figcaption,
+footer,
+header,
+hgroup,
+menu,
+nav,
+output,
+ruby,
+section,
+summary,
+time,
+mark,
+audio,
+video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+}
+
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+menu,
+nav,
+section {
+ display: block;
+}
+
+body {
+ line-height: 1;
+}
+
+ol,
+ul {
+ list-style: none;
+}
+
+blockquote,
+q {
+ quotes: none;
+}
+
+blockquote::before,
+blockquote::after,
+q::before,
+q::after {
+ content: '';
+ content: none;
+}
+
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
diff --git a/src/presentation/shared/components/PageFooter.vue b/src/presentation/shared/components/PageFooter.vue
new file mode 100644
index 0000000..89e9aa6
--- /dev/null
+++ b/src/presentation/shared/components/PageFooter.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
diff --git a/src/presentation/shared/components/page-header/AboutButton.vue b/src/presentation/shared/components/page-header/AboutButton.vue
new file mode 100644
index 0000000..c4f44fc
--- /dev/null
+++ b/src/presentation/shared/components/page-header/AboutButton.vue
@@ -0,0 +1,24 @@
+
+ {{ label }}
+
+
+
diff --git a/src/presentation/shared/components/page-header/AppLogo.vue b/src/presentation/shared/components/page-header/AppLogo.vue
new file mode 100644
index 0000000..0be9bfb
--- /dev/null
+++ b/src/presentation/shared/components/page-header/AppLogo.vue
@@ -0,0 +1,14 @@
+
+
+
+ 4 b i t
+
+ Terminal Color Scheme Designer
+
+
+
+
diff --git a/src/presentation/shared/components/page-header/MainMenu.vue b/src/presentation/shared/components/page-header/MainMenu.vue
new file mode 100644
index 0000000..ccda4af
--- /dev/null
+++ b/src/presentation/shared/components/page-header/MainMenu.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
diff --git a/src/presentation/shared/components/page-header/PageHeaderButton.vue b/src/presentation/shared/components/page-header/PageHeaderButton.vue
new file mode 100644
index 0000000..0707523
--- /dev/null
+++ b/src/presentation/shared/components/page-header/PageHeaderButton.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
diff --git a/src/presentation/shared/stores/calculated-scheme.js b/src/presentation/shared/stores/calculated-scheme.js
new file mode 100644
index 0000000..5bce5d1
--- /dev/null
+++ b/src/presentation/shared/stores/calculated-scheme.js
@@ -0,0 +1,12 @@
+import { defineStore } from 'pinia';
+import { ACHROMATIC_COLOR_NAMES } from '../../../domain/scheme/color-names';
+import { CHROMATIC_COLOR_NAMES } from '../../../domain/scheme/color-names';
+import { SPECIAL_COLOR_NAMES } from '../../../domain/scheme/color-names';
+
+export const useCalculatedSchemeStore = defineStore('calculatedScheme', {
+ state: () => ({
+ calculatedScheme: Object.fromEntries(
+ [...ACHROMATIC_COLOR_NAMES, ...CHROMATIC_COLOR_NAMES, ...SPECIAL_COLOR_NAMES].map(colorName => [colorName, null])
+ )
+ })
+})
diff --git a/src/presentation/shared/stores/scheme.js b/src/presentation/shared/stores/scheme.js
new file mode 100644
index 0000000..e5e65b3
--- /dev/null
+++ b/src/presentation/shared/stores/scheme.js
@@ -0,0 +1,74 @@
+import { defineStore } from 'pinia';
+import {
+ cloneScheme,
+ createDefaultScheme,
+} from '../../../domain/scheme/scheme-defaults';
+import {
+ applyColorModeToScheme,
+ applyHueDistanceToScheme,
+ clampLightnessRange,
+ clampSaturationRange,
+ normalizeSchemeRanges,
+} from '../../../domain/scheme/scheme-state';
+
+export const useSchemeStore = defineStore('scheme', {
+ state: () => ({
+ scheme: createDefaultScheme(),
+ }),
+ actions: {
+ resetScheme() {
+ this.scheme = createDefaultScheme();
+ },
+ replaceScheme(scheme) {
+ this.scheme = normalizeSchemeRanges(cloneScheme(scheme));
+ },
+ setHue(hue) {
+ this.scheme.hue = hue;
+ },
+ setSaturation(saturation) {
+ this.scheme.saturation = saturation;
+ },
+ setChromaticLightnessRange(normalLightness, brightLightness) {
+ this.scheme.normalChromaticLightness = normalLightness;
+ this.scheme.brightChromaticLightness = brightLightness;
+ },
+ setBlackLightnessRange(normalLightness, brightLightness) {
+ this.scheme.normalBlackLightness = normalLightness;
+ this.scheme.brightBlackLightness = brightLightness;
+ },
+ setWhiteLightnessRange(normalLightness, brightLightness) {
+ this.scheme.normalWhiteLightness = normalLightness;
+ this.scheme.brightWhiteLightness = brightLightness;
+ },
+ setDyeColor(color) {
+ this.scheme.dyeColor = { ...color };
+ },
+ setDyeScope(scope) {
+ this.scheme.dyeScope = scope;
+ },
+ setBackgroundColor(color) {
+ this.scheme.customBackgroundColor = { ...color };
+ },
+ setBackgroundMode(mode) {
+ this.scheme.background = mode;
+ },
+ setForegroundColor(color) {
+ this.scheme.customForegroundColor = { ...color };
+ },
+ setForegroundMode(mode) {
+ this.scheme.foreground = mode;
+ },
+ setColorMode(mode) {
+ return applyColorModeToScheme(this.scheme, mode);
+ },
+ setHueDistance(distance) {
+ return applyHueDistanceToScheme(this.scheme, distance);
+ },
+ setSaturationRange(range) {
+ this.scheme.saturationRange = clampSaturationRange(range);
+ },
+ setLightnessRange(range) {
+ this.scheme.lightnessRange = clampLightnessRange(range);
+ },
+ },
+});
diff --git a/src/presentation/shared/theme/css-theme-variables.js b/src/presentation/shared/theme/css-theme-variables.js
new file mode 100644
index 0000000..31f63ce
--- /dev/null
+++ b/src/presentation/shared/theme/css-theme-variables.js
@@ -0,0 +1,24 @@
+import { COLOR_NAMES, SPECIAL_COLOR_NAMES } from '../../../domain/scheme/color-names';
+
+function cssVariableName(colorName) {
+ return `--color-${colorName.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()}`;
+}
+
+export function applyThemeVariables(colors, themeRoot = document.body) {
+ [...COLOR_NAMES, ...SPECIAL_COLOR_NAMES].forEach((colorName) => {
+ const color = colors[colorName];
+ const variableName = cssVariableName(colorName);
+
+ if (color) {
+ themeRoot.style.setProperty(variableName, color.hex());
+ } else {
+ themeRoot.style.removeProperty(variableName);
+ }
+ });
+}
+
+export function clearThemeVariables(themeRoot = document.body) {
+ [...COLOR_NAMES, ...SPECIAL_COLOR_NAMES].forEach((colorName) => {
+ themeRoot.style.removeProperty(cssVariableName(colorName));
+ });
+}
diff --git a/tests/application/terminal-preview/ansi-terminal-sequence.test.js b/tests/application/terminal-preview/ansi-terminal-sequence.test.js
new file mode 100644
index 0000000..2e3c04c
--- /dev/null
+++ b/tests/application/terminal-preview/ansi-terminal-sequence.test.js
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import {
+ joinTerminalLines,
+ resetSgr,
+ sgr,
+ styledText,
+ TERMINAL_LINE_BREAK,
+} from '../../../src/application/terminal-preview/ansi-terminal-sequence';
+
+describe('ansi-terminal-sequence', () => {
+ it('renders SGR styling primitives', () => {
+ expect(sgr()).toBe('');
+ expect(sgr(1, 32)).toBe('\x1b[1;32m');
+ expect(resetSgr()).toBe('\x1b[0m');
+ expect(styledText('help', [32])).toBe('\x1b[32mhelp\x1b[0m');
+ });
+
+ it('joins terminal lines with CRLF', () => {
+ expect(TERMINAL_LINE_BREAK).toBe('\r\n');
+ expect(joinTerminalLines(['one', 'two'])).toBe('one\r\ntwo');
+ });
+});
diff --git a/tests/application/terminal-preview/build-terminal-preview-sequence.test.js b/tests/application/terminal-preview/build-terminal-preview-sequence.test.js
new file mode 100644
index 0000000..f860ce0
--- /dev/null
+++ b/tests/application/terminal-preview/build-terminal-preview-sequence.test.js
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { buildTerminalPreviewSequence } from '../../../src/application/terminal-preview/build-terminal-preview-sequence';
+
+function stripAnsi(value) {
+ return value.replace(/\x1b\[[0-9;]*m/g, '');
+}
+
+describe('buildTerminalPreviewSequence', () => {
+ it('renders the visible terminal preview content', () => {
+ const sequence = buildTerminalPreviewSequence();
+ const plainText = stripAnsi(sequence);
+
+ expect(plainText).toContain('Welcome to 4bit, the Terminal Color Scheme Designer.');
+ expect(plainText).toContain('Type help to see available commands.');
+ expect(plainText).toContain('ciembor@browser ~> colors');
+ expect(plainText).toContain('ciembor@browser ~>');
+ expect(plainText).toContain('40m');
+ });
+
+ it('renders colored shell prompt segments with SGR resets', () => {
+ const sequence = buildTerminalPreviewSequence();
+
+ expect(sequence).toContain('\x1b[32mhelp\x1b[0m');
+ expect(sequence).toContain('\x1b[36mciembor\x1b[0m');
+ expect(sequence).toContain('> colors');
+ expect(sequence).not.toContain('\x1b[34mcolors\x1b[0m');
+ expect(sequence).toContain('\r\n');
+ });
+});
diff --git a/tests/application/terminal-preview/commands/build-colors-preview-command.test.js b/tests/application/terminal-preview/commands/build-colors-preview-command.test.js
new file mode 100644
index 0000000..716644b
--- /dev/null
+++ b/tests/application/terminal-preview/commands/build-colors-preview-command.test.js
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import { buildColorsPreviewCommand } from '../../../../src/application/terminal-preview/commands/build-colors-preview-command';
+
+function stripAnsi(value) {
+ return value.replace(/\x1b\[[0-9;]*m/g, '');
+}
+
+describe('buildColorsPreviewCommand', () => {
+ it('renders the color table preview as a standalone terminal command result', () => {
+ const commandOutput = buildColorsPreviewCommand();
+ const plainText = stripAnsi(commandOutput);
+
+ expect(plainText).toContain('40m');
+ expect(plainText).toContain('47m');
+ expect(plainText).toContain('1;30m');
+ expect(plainText).toContain('1;37m');
+ expect(plainText.match(/gYw/g)).toHaveLength(162);
+ });
+
+ it('keeps the first row label column aligned for long SGR labels', () => {
+ const commandOutput = buildColorsPreviewCommand();
+ const plainText = stripAnsi(commandOutput);
+
+ expect(plainText).toContain(' 1;30m gYw');
+ });
+
+ it('renders colored table cells with SGR resets', () => {
+ const commandOutput = buildColorsPreviewCommand();
+
+ expect(commandOutput).toContain('\x1b[1;30;40m');
+ expect(commandOutput).toContain('\x1b[1;37;47m');
+ expect(commandOutput).toContain('\r\n');
+ });
+});
diff --git a/tests/application/terminal-preview/commands/build-diff-preview-command.test.js b/tests/application/terminal-preview/commands/build-diff-preview-command.test.js
new file mode 100644
index 0000000..e76cb6f
--- /dev/null
+++ b/tests/application/terminal-preview/commands/build-diff-preview-command.test.js
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+import { buildDiffPreviewCommand } from '../../../../src/application/terminal-preview/commands/build-diff-preview-command';
+
+function stripAnsi(value) {
+ return value.replace(/\x1b\[[0-9;]*m/g, '');
+}
+
+describe('buildDiffPreviewCommand', () => {
+ it('renders a compact git diff preview', () => {
+ const output = buildDiffPreviewCommand();
+ const plainText = stripAnsi(output);
+
+ expect(plainText).toContain('diff --git a/theme.css b/theme.css');
+ expect(plainText).toContain('--- a/theme.css');
+ expect(plainText).toContain('+++ b/theme.css');
+ expect(plainText).toContain('- color: #d7d7d7;');
+ expect(plainText).toContain('+ color: #f0f0f0;');
+ expect(plainText).toContain(' .terminal .prompt {');
+ expect(plainText).toContain('- color: #5fafd7;');
+ expect(plainText).toContain('+ color: #55ffff;');
+ expect(plainText).toContain(' .terminal .selection {');
+ expect(plainText).toContain('- background: #444444;');
+ expect(plainText).toContain('+ background: #808080;');
+ });
+
+ it('uses red and green SGR colors for removed and added lines', () => {
+ const output = buildDiffPreviewCommand();
+
+ expect(output).toContain('\x1b[31m--- a/theme.css\x1b[0m');
+ expect(output).toContain('\x1b[32m+++ b/theme.css\x1b[0m');
+ expect(output).toContain('\x1b[31m- color: #d7d7d7;\x1b[0m');
+ expect(output).toContain('\x1b[32m+ color: #f0f0f0;\x1b[0m');
+ expect(output).toContain('\x1b[31m- color: #5fafd7;\x1b[0m');
+ expect(output).toContain('\x1b[32m+ color: #55ffff;\x1b[0m');
+ expect(output).toContain('\x1b[31m- background: #444444;\x1b[0m');
+ expect(output).toContain('\x1b[32m+ background: #808080;\x1b[0m');
+ });
+});
diff --git a/tests/application/terminal-preview/commands/build-git-status-preview-command.test.js b/tests/application/terminal-preview/commands/build-git-status-preview-command.test.js
new file mode 100644
index 0000000..4638710
--- /dev/null
+++ b/tests/application/terminal-preview/commands/build-git-status-preview-command.test.js
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { buildGitStatusPreviewCommand } from '../../../../src/application/terminal-preview/commands/build-git-status-preview-command';
+
+function stripAnsi(value) {
+ return value.replace(/\x1b\[[0-9;]*m/g, '');
+}
+
+describe('buildGitStatusPreviewCommand', () => {
+ it('renders a compact git status preview', () => {
+ const output = buildGitStatusPreviewCommand();
+ const plainText = stripAnsi(output);
+
+ expect(plainText).toContain('On branch master');
+ expect(plainText).toContain('Changes to be committed:');
+ expect(plainText).toContain('Changes not staged for commit:');
+ expect(plainText).toContain('Untracked files:');
+ expect(plainText).toContain('new file: src/application/terminal-preview/commands/build-git-status-preview-command.js');
+ });
+
+ it('uses conventional git status colors', () => {
+ const output = buildGitStatusPreviewCommand();
+
+ expect(output).toContain('\x1b[36mmaster\x1b[0m');
+ expect(output).toContain('\x1b[32m1 commit\x1b[0m');
+ expect(output).toContain('\x1b[32mmodified: src/application/terminal-preview/build-terminal-preview-sequence.js\x1b[0m');
+ expect(output).toContain('\x1b[31mmodified: src/presentation/editor-page/terminal-preview/terminal-view.js\x1b[0m');
+ expect(output).toContain('\x1b[31mtests/application/terminal-preview/commands/build-git-status-preview-command.test.js\x1b[0m');
+ });
+});
diff --git a/tests/application/terminal-preview/commands/build-ls-preview-command.test.js b/tests/application/terminal-preview/commands/build-ls-preview-command.test.js
new file mode 100644
index 0000000..846dbbf
--- /dev/null
+++ b/tests/application/terminal-preview/commands/build-ls-preview-command.test.js
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+import {
+ buildLsAllPreviewCommand,
+ buildLsPreviewCommand,
+} from '../../../../src/application/terminal-preview/commands/build-ls-preview-command';
+
+function stripAnsi(value) {
+ return value.replace(/\x1b\[[0-9;]*m/g, '');
+}
+
+function lineLengths(value) {
+ return stripAnsi(value).split('\r\n').map((line) => line.length);
+}
+
+describe('buildLsPreviewCommand', () => {
+ it('renders a compact ls preview with the same entries as ls -al', () => {
+ const output = buildLsPreviewCommand();
+ const plainText = stripAnsi(output);
+
+ expect(plainText).toContain('about');
+ expect(plainText).toContain('node_modules');
+ expect(plainText).toContain('package-lock.json');
+ expect(plainText).toContain('current -> dist');
+ expect(plainText).toContain('public-assets -> dist/assets');
+ expect(plainText).toContain('status.pipe');
+ expect(plainText).not.toContain('drwxr-xr-x');
+ expect(lineLengths(output).every((length) => length <= 80)).toBe(true);
+ });
+
+ it('renders a compact ls -al preview', () => {
+ const output = buildLsAllPreviewCommand();
+ const plainText = stripAnsi(output);
+
+ expect(plainText).toContain('total 120');
+ expect(plainText).toContain('drwxr-xr-x');
+ expect(plainText).toContain('-rwxr-xr-x');
+ expect(plainText).toContain('node_modules');
+ expect(plainText).toContain('package-lock.json');
+ expect(plainText).toContain('vite.config.js');
+ expect(plainText).toContain('current -> dist');
+ expect(plainText).toContain('public-assets -> dist/assets');
+ expect(plainText).toContain('theme.tar.gz');
+ expect(plainText).toContain('preview.sock');
+ expect(plainText).toContain('status.pipe');
+ });
+
+ it('uses conventional dircolors-inspired SGR colors for file types', () => {
+ const output = buildLsPreviewCommand();
+ const allOutput = buildLsAllPreviewCommand();
+
+ expect(output).toContain('\x1b[1;34msrc\x1b[0m');
+ expect(output).toContain('\x1b[1;32mbuild.sh\x1b[0m');
+ expect(output).toContain('\x1b[1;32mdeploy.sh\x1b[0m');
+ expect(output).toContain('\x1b[1;36mcurrent\x1b[0m');
+ expect(output).toContain('\x1b[1;36mpublic-assets\x1b[0m');
+ expect(output).toContain('\x1b[1;31mtheme.tar.gz\x1b[0m');
+ expect(output).toContain('\x1b[1;35mpreview.sock\x1b[0m');
+ expect(output).toContain('\x1b[40;33;1mstatus.pipe\x1b[0m');
+ expect(allOutput).toContain('\x1b[1;34msrc\x1b[0m');
+ expect(allOutput).toContain('\x1b[1;32mbuild.sh\x1b[0m');
+ expect(allOutput).toContain('\x1b[1;36mcurrent\x1b[0m');
+ });
+});
diff --git a/tests/application/terminal-preview/commands/build-usability-preview-command.test.js b/tests/application/terminal-preview/commands/build-usability-preview-command.test.js
new file mode 100644
index 0000000..854b01f
--- /dev/null
+++ b/tests/application/terminal-preview/commands/build-usability-preview-command.test.js
@@ -0,0 +1,95 @@
+import Color from 'color';
+import { describe, expect, it } from 'vitest';
+import {
+ buildUsabilityPreviewCommand,
+ contrastRatio,
+} from '../../../../src/application/terminal-preview/commands/build-usability-preview-command';
+
+function stripAnsi(value) {
+ return value.replace(/\x1b\[[0-9;]*m/g, '');
+}
+
+function colors(overrides = {}) {
+ return {
+ background: Color('#101010'),
+ foreground: Color('#f0f0f0'),
+ black: Color('#000000'),
+ red: Color('#cc0000'),
+ green: Color('#00aa00'),
+ yellow: Color('#cccc00'),
+ blue: Color('#0000aa'),
+ magenta: Color('#cc00cc'),
+ cyan: Color('#00cccc'),
+ white: Color('#cccccc'),
+ brightBlack: Color('#808080'),
+ brightRed: Color('#ff5555'),
+ brightGreen: Color('#55ff55'),
+ brightYellow: Color('#ffff55'),
+ brightBlue: Color('#5555ff'),
+ brightMagenta: Color('#ff55ff'),
+ brightCyan: Color('#55ffff'),
+ brightWhite: Color('#ffffff'),
+ ...overrides,
+ };
+}
+
+describe('buildUsabilityPreviewCommand', () => {
+ it('calculates WCAG contrast ratios', () => {
+ expect(contrastRatio(Color('#ffffff'), Color('#000000'))).toBeCloseTo(21, 1);
+ expect(contrastRatio(Color('#777777'), Color('#777777'))).toBeCloseTo(1, 1);
+ });
+
+ it('renders a one-screen usability report for the current scheme', () => {
+ const output = buildUsabilityPreviewCommand(colors());
+ const plainText = stripAnsi(output);
+
+ expect(plainText).not.toContain('Terminal usability');
+ expect(plainText).toContain('Checks if terminal text colors stay readable on the background.');
+ expect(plainText).toContain('Uses WCAG 2.x contrast: PASS >= 4.5:1, WARN >= 3.0:1, FAIL < 3.0:1.');
+ expect(output).toContain('Uses \x1b[1mWCAG\x1b[0m 2.x contrast');
+ expect(output).toContain('\x1b[32mPASS\x1b[0m >= 4.5:1');
+ expect(output).toContain('\x1b[33mWARN\x1b[0m >= 3.0:1');
+ expect(output).toContain('\x1b[31mFAIL\x1b[0m < 3.0:1');
+ expect(plainText).toContain('foreground');
+ expect(plainText).toContain('bright red');
+ expect(plainText).toContain('bright cyan');
+ expect(plainText).not.toContain('red-green');
+ expect(plainText).not.toContain('selection');
+ expect(plainText).not.toContain('cursor');
+ expect(plainText).not.toContain('verdict:');
+ expect(plainText.split('\r\n').length).toBeLessThanOrEqual(24);
+ expect(Math.max(...plainText.split('\r\n').map((line) => line.length))).toBeLessThanOrEqual(80);
+ });
+
+ it('aligns bright colors with their matching normal colors', () => {
+ const output = stripAnsi(buildUsabilityPreviewCommand(colors()));
+
+ expect(output).toContain('| black');
+ expect(output).toContain('| bright black');
+ expect(output).toContain('| red');
+ expect(output).toContain('| bright red');
+ expect(output).toContain('| green');
+ expect(output).toContain('| bright green');
+ });
+
+ it('renders color names with their terminal colors', () => {
+ const output = buildUsabilityPreviewCommand(colors());
+
+ expect(output).toContain('\x1b[31mred\x1b[0m');
+ expect(output).toContain('\x1b[32mgreen\x1b[0m');
+ expect(output).toContain('\x1b[34mblue\x1b[0m');
+ expect(output).toContain('\x1b[91mbright red\x1b[0m');
+ expect(output).toContain('\x1b[97mbright white\x1b[0m');
+ });
+
+ it('changes report values when the scheme changes', () => {
+ const readableOutput = stripAnsi(buildUsabilityPreviewCommand(colors()));
+ const lowContrastOutput = stripAnsi(buildUsabilityPreviewCommand(colors({
+ foreground: Color('#111111'),
+ })));
+
+ expect(readableOutput).not.toBe(lowContrastOutput);
+ expect(lowContrastOutput).toContain('foreground');
+ expect(lowContrastOutput).toContain('FAIL');
+ });
+});
diff --git a/tests/application/terminal-preview/terminal-preview-shell.test.js b/tests/application/terminal-preview/terminal-preview-shell.test.js
new file mode 100644
index 0000000..e7007d6
--- /dev/null
+++ b/tests/application/terminal-preview/terminal-preview-shell.test.js
@@ -0,0 +1,124 @@
+import { describe, expect, it } from 'vitest';
+import Color from 'color';
+import {
+ renderTerminalPreviewHelpLine,
+ renderTerminalPreviewPrompt,
+ TERMINAL_PREVIEW_CLEAR_COMMAND,
+ runTerminalPreviewCommand,
+} from '../../../src/application/terminal-preview/terminal-preview-shell';
+
+function stripAnsi(value) {
+ return value.replace(/\x1b\[[0-9;]*m/g, '');
+}
+
+describe('terminal-preview-shell', () => {
+ it('renders the shell prompt and help line', () => {
+ expect(stripAnsi(renderTerminalPreviewHelpLine())).toBe('Type help to see available commands.');
+ expect(stripAnsi(renderTerminalPreviewPrompt())).toBe('ciembor@browser ~> ');
+ expect(stripAnsi(renderTerminalPreviewPrompt({ command: 'colors' }))).toBe('ciembor@browser ~> colors');
+ });
+
+ it('runs the colors preview command', () => {
+ const output = runTerminalPreviewCommand('colors');
+
+ expect(stripAnsi(output)).toContain('40m');
+ expect(stripAnsi(output).match(/gYw/g)).toHaveLength(162);
+ });
+
+ it('runs the initial colors command for the boot transcript', () => {
+ const output = runTerminalPreviewCommand('colors');
+
+ expect(stripAnsi(output)).toContain('1;37m');
+ });
+
+ it('renders help and unknown command output', () => {
+ const helpOutput = runTerminalPreviewCommand('help');
+ const unknownOutput = runTerminalPreviewCommand('wat');
+
+ expect(stripAnsi(helpOutput)).toContain('Tools:');
+ expect(stripAnsi(helpOutput)).toContain('Examples:');
+ expect(stripAnsi(helpOutput)).toContain('clear Clear the terminal screen.');
+ expect(stripAnsi(helpOutput)).toContain('colors Show the ANSI color matrix.');
+ expect(stripAnsi(helpOutput)).toContain('usability Check WCAG-based text contrast.');
+ expect(stripAnsi(helpOutput)).toContain('git diff Show a colored git diff sample.');
+ expect(stripAnsi(helpOutput)).toContain('git status Show a colored git status sample.');
+ expect(stripAnsi(helpOutput)).toContain('ls Show a compact directory listing.');
+ expect(stripAnsi(helpOutput)).toContain('ls -al Show a detailed directory listing.');
+ expect(helpOutput).toContain('\x1b[35musability\x1b[0m');
+ expect(helpOutput).toContain('\x1b[36mgit diff\x1b[0m');
+ expect(helpOutput).not.toContain('\x1b[35mwcag\x1b[0m');
+ expect(unknownOutput).toContain('zsh: command not found: wat');
+ expect(stripAnsi(unknownOutput)).toContain('Tools:');
+ expect(stripAnsi(unknownOutput)).toContain('Examples:');
+ expect(stripAnsi(unknownOutput)).toContain('usability');
+ expect(stripAnsi(unknownOutput)).not.toContain('wcag');
+ expect(runTerminalPreviewCommand(' ')).toBe('');
+ });
+
+ it('runs the clear control command', () => {
+ expect(runTerminalPreviewCommand('clear')).toBe(TERMINAL_PREVIEW_CLEAR_COMMAND);
+ });
+
+ it('runs the git diff preview command', () => {
+ const output = runTerminalPreviewCommand('git diff');
+
+ expect(stripAnsi(output)).toContain('diff --git a/theme.css b/theme.css');
+ expect(output).toContain('\x1b[31m- color: #d7d7d7;\x1b[0m');
+ expect(output).toContain('\x1b[32m+ color: #f0f0f0;\x1b[0m');
+ });
+
+ it('runs the ls preview command', () => {
+ const output = runTerminalPreviewCommand('ls');
+
+ expect(stripAnsi(output)).toContain('README.md');
+ expect(stripAnsi(output)).toContain('current -> dist');
+ expect(stripAnsi(output)).not.toContain('drwxr-xr-x');
+ expect(output).toContain('\x1b[1;34msrc\x1b[0m');
+ expect(output).toContain('\x1b[1;32mbuild.sh\x1b[0m');
+ expect(output).toContain('\x1b[1;36mcurrent\x1b[0m');
+ });
+
+ it('runs the ls -al preview command', () => {
+ const output = runTerminalPreviewCommand('ls -al');
+
+ expect(stripAnsi(output)).toContain('drwxr-xr-x');
+ expect(stripAnsi(output)).toContain('current -> dist');
+ expect(output).toContain('\x1b[1;34msrc\x1b[0m');
+ });
+
+ it('runs the git status preview command', () => {
+ const output = runTerminalPreviewCommand('git status');
+
+ expect(stripAnsi(output)).toContain('On branch master');
+ expect(stripAnsi(output)).toContain('Changes to be committed:');
+ expect(output).toContain('\x1b[32m1 commit\x1b[0m');
+ expect(output).toContain('\x1b[31mdeleted: TODO.md\x1b[0m');
+ });
+
+ it('runs the dynamic usability preview command with live color context', () => {
+ const output = runTerminalPreviewCommand('usability', {
+ colors: {
+ background: Color('#000000'),
+ foreground: Color('#ffffff'),
+ black: Color('#000000'),
+ red: Color('#cc0000'),
+ green: Color('#00cc00'),
+ yellow: Color('#cccc00'),
+ blue: Color('#0000cc'),
+ magenta: Color('#cc00cc'),
+ cyan: Color('#00cccc'),
+ white: Color('#cccccc'),
+ brightBlack: Color('#808080'),
+ brightWhite: Color('#ffffff'),
+ },
+ });
+
+ expect(output.type).toBe('dynamic');
+ expect(stripAnsi(output.content)).toContain('Checks if terminal text colors stay readable');
+ expect(stripAnsi(output.content)).toContain('foreground');
+ });
+
+ it('keeps the WCAG command as an alias', () => {
+ expect(runTerminalPreviewCommand('wcag', { colors: {} }).type).toBe('dynamic');
+ });
+});
diff --git a/tests/domain/scheme/color-mode.test.js b/tests/domain/scheme/color-mode.test.js
new file mode 100644
index 0000000..ede05ec
--- /dev/null
+++ b/tests/domain/scheme/color-mode.test.js
@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest';
+import {
+ clampHueDistance,
+ colorModeHueCycle,
+ degreesEqual,
+ degreesForColorMode,
+ inferColorModeFromDegrees,
+ isColorModeValue,
+ normalizeColorModeValue,
+ normalizeHueForColorMode,
+} from '../../../src/domain/scheme/color-mode';
+
+describe('ColorMode', () => {
+ it('uses complementary families for duotone presets', () => {
+ expect(degreesForColorMode('duotone', 20)).toEqual([0, 20, 180, 200, 160, 340]);
+ });
+
+ it('returns hue cycles that match each preset symmetry', () => {
+ expect(colorModeHueCycle('monochrome')).toBe(360);
+ expect(colorModeHueCycle('duotone')).toBe(180);
+ expect(colorModeHueCycle('tricolor')).toBe(120);
+ expect(colorModeHueCycle('hexachrome')).toBe(60);
+ expect(colorModeHueCycle('custom')).toBe(360);
+ });
+
+ it('normalizes hues into the visible slider range for each preset', () => {
+ expect(normalizeHueForColorMode(200, 'monochrome')).toBe(-160);
+ expect(normalizeHueForColorMode(210, 'duotone')).toBe(30);
+ expect(normalizeHueForColorMode(100, 'tricolor')).toBe(-20);
+ expect(normalizeHueForColorMode(75, 'hexachrome')).toBe(15);
+ expect(normalizeHueForColorMode('oops', 'duotone')).toBe(0);
+ });
+
+ it('accepts aliases and validates known color modes', () => {
+ expect(normalizeColorModeValue('duo')).toBe('duotone');
+ expect(normalizeColorModeValue('standard')).toBe('hexachrome');
+ expect(normalizeColorModeValue('unknown')).toBeNull();
+
+ expect(isColorModeValue('trio')).toBe(true);
+ expect(isColorModeValue('custom')).toBe(false);
+ });
+
+ it('clamps hue distance to the supported integer range', () => {
+ expect(clampHueDistance(20.4)).toBe(20);
+ expect(clampHueDistance(999)).toBe(45);
+ expect(clampHueDistance(-1)).toBe(0);
+ expect(clampHueDistance('oops')).toBe(0);
+ });
+
+ it('compares and infers color modes from degree sets', () => {
+ const duotoneDegrees = degreesForColorMode('duotone', 20);
+
+ expect(degreesEqual(duotoneDegrees, [0, 20, 180, 200, 160, 340])).toBe(true);
+ expect(degreesEqual(duotoneDegrees, [0, 20, 180])).toBe(false);
+ expect(degreesEqual(null, duotoneDegrees)).toBe(false);
+
+ expect(inferColorModeFromDegrees(duotoneDegrees, 20)).toBe('duotone');
+ expect(inferColorModeFromDegrees([1, 2, 3, 4, 5, 6], 20)).toBeNull();
+ });
+});
diff --git a/tests/domain/scheme/color-scheme-calculator.test.js b/tests/domain/scheme/color-scheme-calculator.test.js
new file mode 100644
index 0000000..f65d7b3
--- /dev/null
+++ b/tests/domain/scheme/color-scheme-calculator.test.js
@@ -0,0 +1,269 @@
+import { describe, expect, it } from 'vitest';
+import Color from 'color';
+import { calculateSchemeColors } from '../../../src/domain/scheme/color-scheme-calculator';
+
+function createScheme(overrides = {}) {
+ return {
+ hue: -15,
+ degrees: [0, 60, 120, 180, 240, 300],
+ saturation: 50,
+ saturationRange: 0,
+ normalChromaticLightness: 50,
+ brightChromaticLightness: 75,
+ lightnessRange: 0,
+ normalBlackLightness: 0,
+ brightBlackLightness: 12.5,
+ normalWhiteLightness: 87.5,
+ brightWhiteLightness: 100,
+ dyeScope: 'none',
+ dyeColor: {
+ hue: 180,
+ saturation: 50,
+ lightness: 50,
+ alpha: 0.25,
+ ...overrides.dyeColor,
+ },
+ background: 'black',
+ customBackgroundColor: {
+ hue: 180,
+ saturation: 50,
+ lightness: 10,
+ ...overrides.customBackgroundColor,
+ },
+ foreground: 'white',
+ customForegroundColor: {
+ hue: 180,
+ saturation: 50,
+ lightness: 90,
+ ...overrides.customForegroundColor,
+ },
+ ...overrides,
+ };
+}
+
+function colorHex(color) {
+ return color.hex().toLowerCase();
+}
+
+describe('calculateSchemeColors', () => {
+ it('normalizes wrapped hues into the expected palette', () => {
+ const negativeHueColors = calculateSchemeColors(createScheme({ hue: -15 }));
+ const wrappedHueColors = calculateSchemeColors(createScheme({ hue: 345 }));
+
+ expect(colorHex(negativeHueColors.red)).toBe(colorHex(wrappedHueColors.red));
+ expect(colorHex(negativeHueColors.red)).toBe(colorHex(Color({ h: 345, s: 50, l: 50 })));
+ expect(colorHex(negativeHueColors.green)).toBe(colorHex(Color({ h: 105, s: 50, l: 50 })));
+ expect(colorHex(negativeHueColors.brightBlue)).toBe(colorHex(Color({ h: 225, s: 50, l: 75 })));
+ expect(colorHex(negativeHueColors.background)).toBe(colorHex(negativeHueColors.black));
+ expect(colorHex(negativeHueColors.foreground)).toBe(colorHex(negativeHueColors.white));
+ });
+
+ it('uses custom hue-set degree offsets for chromatic color slots', () => {
+ const colors = calculateSchemeColors(createScheme({
+ hue: 100,
+ degrees: [0, 10, 20, 30, 40, 50],
+ }));
+
+ expect(colorHex(colors.red)).toBe(colorHex(Color({ h: 100, s: 50, l: 50 })));
+ expect(colorHex(colors.yellow)).toBe(colorHex(Color({ h: 110, s: 50, l: 50 })));
+ expect(colorHex(colors.green)).toBe(colorHex(Color({ h: 120, s: 50, l: 50 })));
+ expect(colorHex(colors.cyan)).toBe(colorHex(Color({ h: 130, s: 50, l: 50 })));
+ expect(colorHex(colors.blue)).toBe(colorHex(Color({ h: 140, s: 50, l: 50 })));
+ expect(colorHex(colors.magenta)).toBe(colorHex(Color({ h: 150, s: 50, l: 50 })));
+ });
+
+ it('keeps monochrome hue-distance variants inside one hue family', () => {
+ const colors = calculateSchemeColors(createScheme({
+ hue: 0,
+ degrees: [0, 20, 10, 350, 340, 355],
+ }));
+
+ expect(colorHex(colors.red)).toBe(colorHex(Color({ h: 0, s: 50, l: 50 })));
+ expect(colorHex(colors.yellow)).toBe(colorHex(Color({ h: 20, s: 50, l: 50 })));
+ expect(colorHex(colors.green)).toBe(colorHex(Color({ h: 10, s: 50, l: 50 })));
+ expect(colorHex(colors.cyan)).toBe(colorHex(Color({ h: 350, s: 50, l: 50 })));
+ expect(colorHex(colors.blue)).toBe(colorHex(Color({ h: 340, s: 50, l: 50 })));
+ expect(colorHex(colors.magenta)).toBe(colorHex(Color({ h: 355, s: 50, l: 50 })));
+ expect(colorHex(colors.yellow)).not.toBe(colorHex(colors.red));
+ expect(colorHex(colors.blue)).not.toBe(colorHex(colors.red));
+ });
+
+ it('keeps duotone hue-distance variants inside complementary hue families', () => {
+ const colors = calculateSchemeColors(createScheme({
+ hue: 0,
+ degrees: [0, 20, 180, 200, 160, 340],
+ }));
+
+ expect(colorHex(colors.red)).toBe(colorHex(Color({ h: 0, s: 50, l: 50 })));
+ expect(colorHex(colors.yellow)).toBe(colorHex(Color({ h: 20, s: 50, l: 50 })));
+ expect(colorHex(colors.green)).toBe(colorHex(Color({ h: 180, s: 50, l: 50 })));
+ expect(colorHex(colors.cyan)).toBe(colorHex(Color({ h: 200, s: 50, l: 50 })));
+ expect(colorHex(colors.blue)).toBe(colorHex(Color({ h: 160, s: 50, l: 50 })));
+ expect(colorHex(colors.magenta)).toBe(colorHex(Color({ h: 340, s: 50, l: 50 })));
+ expect(colorHex(colors.red)).not.toBe(colorHex(colors.green));
+ expect(colorHex(colors.yellow)).not.toBe(colorHex(colors.red));
+ expect(colorHex(colors.cyan)).not.toBe(colorHex(colors.green));
+ });
+
+ it('keeps the classic hexachrome palette when all advanced distances are neutral', () => {
+ const legacyColors = calculateSchemeColors(createScheme({
+ hue: 0,
+ degrees: [0, 60, 120, 180, 240, 300],
+ saturationRange: 0,
+ lightnessRange: 0,
+ }));
+
+ expect(colorHex(legacyColors.red)).toBe(colorHex(Color({ h: 0, s: 50, l: 50 })));
+ expect(colorHex(legacyColors.yellow)).toBe(colorHex(Color({ h: 60, s: 50, l: 50 })));
+ expect(colorHex(legacyColors.green)).toBe(colorHex(Color({ h: 120, s: 50, l: 50 })));
+ expect(colorHex(legacyColors.cyan)).toBe(colorHex(Color({ h: 180, s: 50, l: 50 })));
+ expect(colorHex(legacyColors.blue)).toBe(colorHex(Color({ h: 240, s: 50, l: 50 })));
+ expect(colorHex(legacyColors.magenta)).toBe(colorHex(Color({ h: 300, s: 50, l: 50 })));
+ });
+
+ it('supports offsetting the hexachrome hue-set while keeping six distinct slots', () => {
+ const colors = calculateSchemeColors(createScheme({
+ hue: 0,
+ degrees: [0, 80, 130, 170, 220, 295],
+ }));
+
+ expect(colorHex(colors.red)).toBe(colorHex(Color({ h: 0, s: 50, l: 50 })));
+ expect(colorHex(colors.yellow)).toBe(colorHex(Color({ h: 80, s: 50, l: 50 })));
+ expect(colorHex(colors.green)).toBe(colorHex(Color({ h: 130, s: 50, l: 50 })));
+ expect(colorHex(colors.cyan)).toBe(colorHex(Color({ h: 170, s: 50, l: 50 })));
+ expect(colorHex(colors.blue)).toBe(colorHex(Color({ h: 220, s: 50, l: 50 })));
+ expect(colorHex(colors.magenta)).toBe(colorHex(Color({ h: 295, s: 50, l: 50 })));
+ expect(new Set([
+ colorHex(colors.red),
+ colorHex(colors.yellow),
+ colorHex(colors.green),
+ colorHex(colors.cyan),
+ colorHex(colors.blue),
+ colorHex(colors.magenta),
+ ]).size).toBe(6);
+ });
+
+ it('applies saturation and lightness ranges across chromatic slots', () => {
+ const colors = calculateSchemeColors(createScheme({
+ hue: 0,
+ saturation: 50,
+ saturationRange: 20,
+ normalChromaticLightness: 50,
+ brightChromaticLightness: 75,
+ lightnessRange: 10,
+ }));
+
+ expect(colorHex(colors.red)).toBe(colorHex(Color({ h: 0, s: 50, l: 50 })));
+ expect(colorHex(colors.yellow)).toBe(colorHex(Color({ h: 60, s: 70, l: 60 })));
+ expect(colorHex(colors.green)).toBe(colorHex(Color({ h: 120, s: 60, l: 55 })));
+ expect(colorHex(colors.cyan)).toBe(colorHex(Color({ h: 180, s: 40, l: 45 })));
+ expect(colorHex(colors.blue)).toBe(colorHex(Color({ h: 240, s: 30, l: 40 })));
+ expect(colorHex(colors.magenta)).toBe(colorHex(Color({ h: 300, s: 45, l: 47.5 })));
+ expect(colorHex(colors.brightYellow)).toBe(colorHex(Color({ h: 60, s: 70, l: 85 })));
+ expect(colorHex(colors.brightBlue)).toBe(colorHex(Color({ h: 240, s: 30, l: 65 })));
+ });
+
+ it('tints only chromatic colors for the legacy color dye scope', () => {
+ const result = calculateSchemeColors(createScheme({
+ dyeScope: 'color',
+ dyeColor: {
+ hue: 180,
+ saturation: 100,
+ lightness: 50,
+ alpha: 1,
+ },
+ }));
+
+ const overlayColor = Color({ h: 180, s: 100, l: 50 });
+
+ expect(colorHex(result.red)).toBe(colorHex(overlayColor));
+ expect(colorHex(result.cyan)).toBe(colorHex(overlayColor));
+ expect(colorHex(result.black)).toBe(colorHex(Color({ h: 0, s: 0, l: 0 })));
+ expect(colorHex(result.white)).toBe(colorHex(Color({ h: 0, s: 0, l: 87.5 })));
+ });
+
+ it('tints achromatic colors when the dye scope is all', () => {
+ const result = calculateSchemeColors(createScheme({
+ dyeScope: 'all',
+ dyeColor: {
+ hue: 180,
+ saturation: 100,
+ lightness: 50,
+ alpha: 1,
+ },
+ }));
+
+ const overlayColor = Color({ h: 180, s: 100, l: 50 });
+
+ expect(colorHex(result.black)).toBe(colorHex(overlayColor));
+ expect(colorHex(result.brightWhite)).toBe(colorHex(overlayColor));
+ expect(colorHex(result.background)).toBe(colorHex(overlayColor));
+ expect(colorHex(result.foreground)).toBe(colorHex(overlayColor));
+ });
+
+ it('tints only achromatic colors when the dye scope is achromatic', () => {
+ const result = calculateSchemeColors(createScheme({
+ dyeScope: 'achromatic',
+ dyeColor: {
+ hue: 180,
+ saturation: 100,
+ lightness: 50,
+ alpha: 1,
+ },
+ }));
+
+ const overlayColor = Color({ h: 180, s: 100, l: 50 });
+
+ expect(colorHex(result.black)).toBe(colorHex(overlayColor));
+ expect(colorHex(result.white)).toBe(colorHex(overlayColor));
+ expect(colorHex(result.red)).toBe(colorHex(Color({ h: 345, s: 50, l: 50 })));
+ expect(colorHex(result.cyan)).toBe(colorHex(Color({ h: 165, s: 50, l: 50 })));
+ });
+
+ it('resolves legacy special color names and custom special colors', () => {
+ const customForegroundColor = {
+ hue: 210,
+ saturation: 60,
+ lightness: 70,
+ };
+ const result = calculateSchemeColors(createScheme({
+ background: 'bright_black',
+ foreground: 'custom',
+ customForegroundColor,
+ }));
+
+ expect(colorHex(result.background)).toBe(colorHex(result.brightBlack));
+ expect(colorHex(result.foreground)).toBe(colorHex(Color({
+ h: customForegroundColor.hue,
+ s: customForegroundColor.saturation,
+ l: customForegroundColor.lightness,
+ })));
+ });
+
+ it('falls back to black and white when custom or unknown special colors cannot be resolved', () => {
+ const result = calculateSchemeColors(createScheme({
+ dyeColor: null,
+ background: 'custom',
+ customBackgroundColor: null,
+ foreground: 'unknown',
+ customForegroundColor: null,
+ }));
+
+ expect(colorHex(result.background)).toBe(colorHex(result.black));
+ expect(colorHex(result.foreground)).toBe(colorHex(result.white));
+ });
+
+ it('treats invalid saturation and lightness ranges as zero adjustments', () => {
+ const baseline = calculateSchemeColors(createScheme({
+ saturationRange: 0,
+ lightnessRange: 0,
+ }));
+ const invalidRanges = calculateSchemeColors(createScheme({
+ saturationRange: 'oops',
+ lightnessRange: Number.POSITIVE_INFINITY,
+ }));
+
+ expect(colorHex(invalidRanges.yellow)).toBe(colorHex(baseline.yellow));
+ expect(colorHex(invalidRanges.brightBlue)).toBe(colorHex(baseline.brightBlue));
+ });
+});
diff --git a/tests/domain/scheme/scheme-defaults.test.js b/tests/domain/scheme/scheme-defaults.test.js
new file mode 100644
index 0000000..6c880ce
--- /dev/null
+++ b/tests/domain/scheme/scheme-defaults.test.js
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+import {
+ cloneScheme,
+ createDefaultScheme,
+ SCHEME_DEFAULTS,
+} from '../../../src/domain/scheme/scheme-defaults';
+
+describe('scheme-defaults', () => {
+ it('creates deep-cloned default schemes', () => {
+ const scheme = createDefaultScheme();
+
+ expect(scheme).toEqual(SCHEME_DEFAULTS);
+ expect(scheme).not.toBe(SCHEME_DEFAULTS);
+ expect(scheme.degrees).not.toBe(SCHEME_DEFAULTS.degrees);
+ expect(scheme.dyeColor).not.toBe(SCHEME_DEFAULTS.dyeColor);
+ expect(scheme.customBackgroundColor).not.toBe(SCHEME_DEFAULTS.customBackgroundColor);
+ expect(scheme.customForegroundColor).not.toBe(SCHEME_DEFAULTS.customForegroundColor);
+ });
+
+ it('clones nested scheme values without sharing references', () => {
+ const original = createDefaultScheme();
+ const cloned = cloneScheme(original);
+
+ cloned.degrees[0] = 42;
+ cloned.dyeColor.hue = 90;
+ cloned.customBackgroundColor.lightness = 33;
+ cloned.customForegroundColor.lightness = 77;
+
+ expect(original.degrees[0]).not.toBe(cloned.degrees[0]);
+ expect(original.dyeColor.hue).not.toBe(cloned.dyeColor.hue);
+ expect(original.customBackgroundColor.lightness).not.toBe(
+ cloned.customBackgroundColor.lightness
+ );
+ expect(original.customForegroundColor.lightness).not.toBe(
+ cloned.customForegroundColor.lightness
+ );
+ });
+});
diff --git a/tests/domain/scheme/scheme-state.test.js b/tests/domain/scheme/scheme-state.test.js
new file mode 100644
index 0000000..ece1f94
--- /dev/null
+++ b/tests/domain/scheme/scheme-state.test.js
@@ -0,0 +1,112 @@
+import { describe, expect, it } from 'vitest';
+import {
+ DEFAULT_HUE_DISTANCE,
+ degreesForColorMode,
+} from '../../../src/domain/scheme/color-mode';
+import { createDefaultScheme } from '../../../src/domain/scheme/scheme-defaults';
+import {
+ applyColorModeToScheme,
+ applyHueDistanceToScheme,
+ clampLightnessRange,
+ clampSaturationRange,
+ DEFAULT_LIGHTNESS_RANGE,
+ DEFAULT_SATURATION_RANGE,
+ normalizeSchemeRanges,
+} from '../../../src/domain/scheme/scheme-state';
+
+describe('scheme-state', () => {
+ it('clamps advanced range values to domain limits', () => {
+ expect(clampSaturationRange(999)).toBe(50);
+ expect(clampSaturationRange(-10)).toBe(0);
+ expect(clampSaturationRange('oops')).toBe(DEFAULT_SATURATION_RANGE);
+
+ expect(clampLightnessRange(999)).toBe(30);
+ expect(clampLightnessRange(-10)).toBe(0);
+ expect(clampLightnessRange(null)).toBe(DEFAULT_LIGHTNESS_RANGE);
+ });
+
+ it('normalizes scheme ranges in place', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.hueDistance = 999;
+ scheme.saturationRange = 999;
+ scheme.lightnessRange = 999;
+
+ expect(normalizeSchemeRanges(scheme)).toBe(scheme);
+ expect(scheme.hueDistance).toBe(45);
+ expect(scheme.saturationRange).toBe(50);
+ expect(scheme.lightnessRange).toBe(30);
+ });
+
+ it('returns the incoming nullish scheme unchanged when normalizing ranges', () => {
+ expect(normalizeSchemeRanges(null)).toBeNull();
+ expect(normalizeSchemeRanges(undefined)).toBeUndefined();
+ });
+
+ it('defaults hue distance while normalizing ranges when it is missing', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.hueDistance = undefined;
+
+ normalizeSchemeRanges(scheme);
+
+ expect(scheme.hueDistance).toBe(DEFAULT_HUE_DISTANCE);
+ });
+
+ it('applies preset color mode invariants to the scheme', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.hue = 210;
+ scheme.hueDistance = 18;
+
+ expect(applyColorModeToScheme(scheme, 'duotone')).toBe(true);
+ expect(scheme.colorMode).toBe('duotone');
+ expect(scheme.hue).toBe(30);
+ expect(scheme.degrees).toEqual(degreesForColorMode('duotone', 18));
+ });
+
+ it('leaves the scheme unchanged for unsupported color modes', () => {
+ const scheme = createDefaultScheme();
+ const original = {
+ colorMode: scheme.colorMode,
+ hue: scheme.hue,
+ degrees: [...scheme.degrees],
+ };
+
+ expect(applyColorModeToScheme(scheme, 'custom')).toBe(false);
+ expect(scheme.colorMode).toBe(original.colorMode);
+ expect(scheme.hue).toBe(original.hue);
+ expect(scheme.degrees).toEqual(original.degrees);
+ });
+
+ it('returns false when applying a color mode to a nullish scheme', () => {
+ expect(applyColorModeToScheme(null, 'duotone')).toBe(false);
+ expect(applyColorModeToScheme(undefined, 'duotone')).toBe(false);
+ });
+
+ it('updates hue distance and recalculates preset degrees', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.colorMode = 'tricolor';
+
+ expect(applyHueDistanceToScheme(scheme, 15)).toBe(15);
+ expect(scheme.hueDistance).toBe(15);
+ expect(scheme.degrees).toEqual(degreesForColorMode('tricolor', 15));
+ });
+
+ it('updates hue distance without changing custom degrees', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.colorMode = 'custom';
+ scheme.degrees = [1, 2, 3, 4, 5, 6];
+
+ expect(applyHueDistanceToScheme(scheme, 999)).toBe(45);
+ expect(scheme.hueDistance).toBe(45);
+ expect(scheme.degrees).toEqual([1, 2, 3, 4, 5, 6]);
+ });
+
+ it('returns the default hue distance for a nullish scheme', () => {
+ expect(applyHueDistanceToScheme(null, 10)).toBe(DEFAULT_HUE_DISTANCE);
+ expect(applyHueDistanceToScheme(undefined, 10)).toBe(DEFAULT_HUE_DISTANCE);
+ });
+});
diff --git a/tests/domain/terminal-preview/terminal-preview-model.test.js b/tests/domain/terminal-preview/terminal-preview-model.test.js
new file mode 100644
index 0000000..b3c84ea
--- /dev/null
+++ b/tests/domain/terminal-preview/terminal-preview-model.test.js
@@ -0,0 +1,92 @@
+import { describe, expect, it } from 'vitest';
+import {
+ TERMINAL_PREVIEW_COLUMNS,
+ TERMINAL_PREVIEW_ROWS,
+ TERMINAL_PREVIEW_SAMPLE_TEXT,
+ terminalPreviewHeaderLines,
+ terminalPreviewPromptCommand,
+} from '../../../src/domain/terminal-preview/terminal-preview-model';
+
+describe('TerminalPreviewModel', () => {
+ it('preserves the visible shell intro and prompt command', () => {
+ expect(terminalPreviewHeaderLines()).toEqual([
+ 'Welcome to 4bit, the Terminal Color Scheme Designer.',
+ 'Type help to see available commands.',
+ ]);
+ expect(terminalPreviewPromptCommand()).toEqual({
+ user: 'ciembor',
+ host: 'browser',
+ directory: '~',
+ command: 'colors',
+ });
+ expect(TERMINAL_PREVIEW_SAMPLE_TEXT).toBe('gYw');
+ });
+
+ it('preserves terminal preview background columns', () => {
+ expect(TERMINAL_PREVIEW_COLUMNS.map((column) => column.colorName)).toEqual([
+ 'background',
+ 'black',
+ 'red',
+ 'green',
+ 'yellow',
+ 'blue',
+ 'magenta',
+ 'cyan',
+ 'white',
+ ]);
+ expect(TERMINAL_PREVIEW_COLUMNS.map((column) => column.label)).toEqual([
+ ' ',
+ '40m',
+ '41m',
+ '42m',
+ '43m',
+ '44m',
+ '45m',
+ '46m',
+ '47m',
+ ]);
+ });
+
+ it('preserves terminal preview foreground rows', () => {
+ expect(TERMINAL_PREVIEW_ROWS.map((row) => row.colorName)).toEqual([
+ 'foreground',
+ 'brightForeground',
+ 'black',
+ 'brightBlack',
+ 'red',
+ 'brightRed',
+ 'green',
+ 'brightGreen',
+ 'yellow',
+ 'brightYellow',
+ 'blue',
+ 'brightBlue',
+ 'magenta',
+ 'brightMagenta',
+ 'cyan',
+ 'brightCyan',
+ 'white',
+ 'brightWhite',
+ ]);
+ expect(TERMINAL_PREVIEW_ROWS.map((row) => row.label)).toEqual([
+ 'm',
+ '1m',
+ '30m',
+ '1;30m',
+ '31m',
+ '1;31m',
+ '32m',
+ '1;32m',
+ '33m',
+ '1;33m',
+ '34m',
+ '1;34m',
+ '35m',
+ '1;35m',
+ '36m',
+ '1;36m',
+ '37m',
+ '1;37m',
+ ]);
+ });
+});
diff --git a/tests/e2e/app.spec.js b/tests/e2e/app.spec.js
new file mode 100644
index 0000000..819921d
--- /dev/null
+++ b/tests/e2e/app.spec.js
@@ -0,0 +1,147 @@
+const { test, expect } = require('@playwright/test');
+
+async function blockThirdPartyRequests(page) {
+ await page.route('https://www.googletagmanager.com/**', (route) => route.abort());
+ await page.route('https://platform.twitter.com/**', (route) => route.abort());
+}
+
+function nestedShareTarget(href, key) {
+ const shareUrl = new URL(href);
+ return new URL(shareUrl.searchParams.get(key));
+}
+
+function minttyDragScheme(target) {
+ const hashParams = new URLSearchParams(target.hash.replace(/^#\?/, ''));
+
+ return hashParams.get('scheme');
+}
+
+function compressedSettings(target) {
+ return target.searchParams.get('s');
+}
+
+async function shareTargets(page) {
+ const xHref = await page.getByLabel('share on x').getAttribute('href');
+ const linkedInHref = await page.getByLabel('share on linkedin').getAttribute('href');
+ const facebookHref = await page.getByLabel('share on facebook').getAttribute('href');
+
+ return {
+ x: nestedShareTarget(xHref, 'url'),
+ linkedIn: nestedShareTarget(linkedInHref, 'url'),
+ facebook: nestedShareTarget(facebookHref, 'u'),
+ };
+}
+
+async function checkRadioValue(page, groupSelector, value) {
+ const input = page.locator(`${groupSelector} input[value="${value}"]`);
+ const inputId = await input.getAttribute('id');
+
+ await page.locator(`${groupSelector} label[for="${inputId}"]`).click();
+}
+
+test.beforeEach(async ({ page }) => {
+ await blockThirdPartyRequests(page);
+});
+
+test('loads the app and renders the main editor controls', async ({ page }) => {
+ await page.goto('/');
+
+ await expect(page.locator('#terminal-display')).toBeVisible();
+ await expect(page.getByText('Welcome to 4bit, the Terminal Color Scheme Designer.')).toBeVisible();
+ await expect(page.locator('#controls')).toBeVisible();
+ await expect(page.locator('#advanced')).toBeVisible();
+ await expect(page.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about/');
+ await expect(page.getByRole('link', { name: 'Download Scheme' })).toBeVisible();
+
+ const targets = await shareTargets(page);
+
+ expect(targets.x.origin).toBe('https://ciembor.github.io');
+ expect(targets.x.pathname).toBe('/4bit/');
+ expect(compressedSettings(targets.x)).toMatch(/^2[A-Za-z0-9_-]+$/);
+ expect(minttyDragScheme(targets.x)).toBeNull();
+ expect(targets.linkedIn.search).toBe(targets.x.search);
+ expect(minttyDragScheme(targets.linkedIn)).toMatch(/^([0-9A-F]{6}:){18}[0-9A-F]{6}$/);
+ expect(targets.facebook.href).toBe(targets.linkedIn.href);
+});
+
+test('opens the about page from the header', async ({ page }) => {
+ await page.goto('/');
+
+ await page.getByRole('link', { name: 'About' }).click();
+
+ await expect(page).toHaveURL(/\/about\/$/);
+ await expect(page.getByRole('heading', { name: 'About 4bit' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Editor' })).toHaveAttribute('href', '../');
+ await expect(page.getByRole('link', { name: 'Download Scheme' })).toHaveCount(0);
+ await expect(page.getByRole('dialog', { name: 'Export scheme to the configuration file' })).toBeHidden();
+ await expect(page.getByText(/terminal color themes/).first()).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Create a Terminal Color Scheme' })).toHaveAttribute('href', '../');
+});
+
+test('hydrates scheme state from the query string and keeps share links in sync', async ({ page }) => {
+ await page.goto('/?hue=12&colorMode=duotone&hueDistance=18&dyeScope=all&background=white');
+
+ await expect(page.locator('#dye-radio input[value="all"]')).toBeChecked();
+ await expect(page.locator('#background-radio input[value="white"]')).toBeChecked();
+ await expect(page.locator('#hue-set-radio input[value="duotone"]')).toBeChecked();
+
+ const targets = await shareTargets(page);
+
+ expect(compressedSettings(targets.x)).toMatch(/^2[A-Za-z0-9_-]+$/);
+ expect(targets.x.searchParams.get('hue')).toBeNull();
+ expect(targets.x.searchParams.get('colorMode')).toBeNull();
+ expect(targets.x.searchParams.get('hueDistance')).toBeNull();
+ expect(targets.x.searchParams.get('dyeScope')).toBeNull();
+ expect(targets.x.searchParams.get('background')).toBeNull();
+ expect(minttyDragScheme(targets.x)).toBeNull();
+ expect(targets.linkedIn.search).toBe(targets.x.search);
+ expect(minttyDragScheme(targets.linkedIn)).toMatch(/^([0-9A-F]{6}:){18}[0-9A-F]{6}$/);
+ expect(targets.facebook.href).toBe(targets.linkedIn.href);
+});
+
+test('updates URL and share links when advanced options change', async ({ page }) => {
+ await page.goto('/');
+
+ await page.getByRole('tab', { name: 'Bg' }).click();
+ await checkRadioValue(page, '#background-radio', 'white');
+ await expect(page.locator('#background-radio input[value="white"]')).toBeChecked();
+
+ await page.getByRole('tab', { name: 'Dye' }).click();
+ await checkRadioValue(page, '#dye-radio', 'all');
+ await expect(page.locator('#dye-radio input[value="all"]')).toBeChecked();
+
+ await page.getByRole('tab', { name: 'Color Mode' }).click();
+ await checkRadioValue(page, '#hue-set-radio', 'duotone');
+ await expect(page.locator('#hue-set-radio input[value="duotone"]')).toBeChecked();
+
+ await expect.poll(() => new URL(page.url()).searchParams.get('s')).toMatch(/^2[A-Za-z0-9_-]+$/);
+ expect(new URL(page.url()).searchParams.get('background')).toBeNull();
+ expect(new URL(page.url()).searchParams.get('dyeScope')).toBeNull();
+ expect(new URL(page.url()).searchParams.get('colorMode')).toBeNull();
+ await expect.poll(() => new URL(page.url()).hash).toMatch(/^#\?scheme=/);
+
+ const targets = await shareTargets(page);
+
+ expect(compressedSettings(targets.x)).toMatch(/^2[A-Za-z0-9_-]+$/);
+ expect(targets.x.searchParams.get('background')).toBeNull();
+ expect(targets.x.searchParams.get('dyeScope')).toBeNull();
+ expect(targets.x.searchParams.get('colorMode')).toBeNull();
+ expect(targets.x.searchParams.get('degrees')).toBeNull();
+ expect(minttyDragScheme(targets.x)).toBeNull();
+});
+
+test('opens the export dialog and downloads an iTerm2 file', async ({ page }) => {
+ await page.goto('/');
+
+ await page.getByRole('link', { name: 'Download Scheme' }).click();
+ await expect(page.getByRole('dialog', { name: 'Export scheme to the configuration file' })).toBeVisible();
+
+ const downloadPromise = page.waitForEvent('download');
+ await page.locator('#iterm2-button').click();
+ const download = await downloadPromise;
+
+ expect(download.suggestedFilename()).toBe('4bit.itermcolors');
+
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('dialog', { name: 'Export scheme to the configuration file' })).toBeHidden();
+});
diff --git a/tests/infrastructure/browser/scheme-url-sync.test.js b/tests/infrastructure/browser/scheme-url-sync.test.js
new file mode 100644
index 0000000..99f85cc
--- /dev/null
+++ b/tests/infrastructure/browser/scheme-url-sync.test.js
@@ -0,0 +1,343 @@
+import { describe, expect, it, vi } from 'vitest';
+import { createPinia } from 'pinia';
+import { useSchemeStore } from '../../../src/presentation/shared/stores/scheme';
+import { calculateSchemeColors } from '../../../src/domain/scheme/color-scheme-calculator';
+import { createDefaultScheme } from '../../../src/domain/scheme/scheme-defaults';
+import { buildMinttyDragSchemeHash } from '../../../src/infrastructure/url/mintty-drag-scheme';
+import { buildSchemeSearch } from '../../../src/infrastructure/url/scheme-query';
+import {
+ hydrateSchemeStoreFromLocation,
+ resolveInitialSchemeSearch,
+ SCHEME_STORAGE_KEY,
+ SchemeUrlSync,
+} from '../../../src/infrastructure/browser/scheme-url-sync';
+
+function expectedMinttyHash(scheme) {
+ return buildMinttyDragSchemeHash(calculateSchemeColors(scheme));
+}
+
+function expectedSchemeSearch(scheme) {
+ return buildSchemeSearch(scheme);
+}
+
+describe('SchemeUrlSync', () => {
+ it('preserves unrelated query params while updating scheme params and mintty drag payload', () => {
+ const scheme = createDefaultScheme();
+ scheme.hue = 10;
+ scheme.dyeScope = 'all';
+
+ const location = {
+ pathname: '/4bit/',
+ search: '?utm_source=readme&hue=5',
+ hash: '#preview',
+ };
+ const history = {
+ state: { any: 'value' },
+ replaceState: vi.fn(),
+ };
+
+ new SchemeUrlSync({
+ schemeStore: { scheme },
+ location,
+ history,
+ }).updateLocation(scheme);
+
+ expect(history.replaceState).toHaveBeenCalledWith(
+ history.state,
+ '',
+ `/4bit/?utm_source=readme&${expectedSchemeSearch(scheme).slice(1)}${expectedMinttyHash(scheme)}`
+ );
+ });
+
+ it('registers a deep immediate watcher and can stop it', () => {
+ const scheme = createDefaultScheme();
+ scheme.hue = 10;
+ const stopWatcher = vi.fn();
+ const watch = vi.fn(() => stopWatcher);
+ const sync = new SchemeUrlSync({
+ schemeStore: { scheme },
+ location: {
+ pathname: '/4bit/',
+ search: '',
+ hash: '',
+ },
+ history: {
+ state: null,
+ replaceState: vi.fn(),
+ },
+ });
+
+ const returnedStop = sync.start(watch);
+
+ expect(watch).toHaveBeenCalledTimes(1);
+ const [source, callback, options] = watch.mock.calls[0];
+ expect(source()).toBe(scheme);
+ expect(options).toEqual({ deep: true, immediate: true });
+ callback(scheme);
+ expect(sync.history.replaceState).toHaveBeenCalled();
+ expect(returnedStop).toBe(stopWatcher);
+
+ sync.stop();
+ expect(stopWatcher).toHaveBeenCalledTimes(1);
+ expect(sync.stopWatcher).toBeNull();
+ });
+
+ it('prefers explicit scheme params in the URL over persisted state', () => {
+ const persistedScheme = createDefaultScheme();
+ persistedScheme.hue = 45;
+ persistedScheme.dyeScope = 'all';
+
+ const storage = {
+ getItem: vi.fn(() => expectedSchemeSearch(persistedScheme)),
+ };
+
+ expect(resolveInitialSchemeSearch('?hue=12', storage)).toBe('?hue=12');
+ });
+
+ it('returns the current search when persisted scheme params are unavailable', () => {
+ expect(resolveInitialSchemeSearch('?utm_source=readme', null)).toBe('?utm_source=readme');
+ });
+
+ it('normalizes current search strings that omit the leading question mark', () => {
+ expect(resolveInitialSchemeSearch('utm_source=readme', null)).toBe('?utm_source=readme');
+ });
+
+ it('ignores persisted search read errors and keeps the current URL search', () => {
+ const storage = {
+ getItem: vi.fn(() => {
+ throw new Error('storage unavailable');
+ }),
+ };
+
+ expect(resolveInitialSchemeSearch('?utm_source=readme', storage)).toBe('?utm_source=readme');
+ });
+
+ it('falls back to persisted scheme params and preserves unrelated URL params', () => {
+ const scheme = createDefaultScheme();
+ scheme.hue = 45;
+ scheme.dyeScope = 'all';
+
+ const storage = {
+ getItem: vi.fn(() => expectedSchemeSearch(scheme)),
+ };
+
+ expect(resolveInitialSchemeSearch('?utm_source=readme', storage))
+ .toBe(`?utm_source=readme&${expectedSchemeSearch(scheme).slice(1)}`);
+ });
+
+ it('ignores persisted search values that do not contain any actual params', () => {
+ const storage = {
+ getItem: vi.fn(() => '?'),
+ };
+
+ expect(resolveInitialSchemeSearch('', storage)).toBe('');
+ });
+
+ it('hydrates the store from persisted search when the URL has no scheme params', () => {
+ const pinia = createPinia();
+ const storage = {
+ getItem: vi.fn(() => '?hue=45&dyeScope=all'),
+ };
+ const location = {
+ pathname: '/4bit/',
+ search: '',
+ hash: '#preview',
+ };
+ const history = {
+ state: { from: 'test' },
+ replaceState: vi.fn(),
+ };
+
+ const schemeStore = hydrateSchemeStoreFromLocation(useSchemeStore(pinia), {
+ search: location.search,
+ storage,
+ location,
+ history,
+ });
+
+ expect(schemeStore.scheme.hue).toBe(45);
+ expect(schemeStore.scheme.dyeScope).toBe('all');
+ expect(history.replaceState).toHaveBeenCalledWith(
+ history.state,
+ '',
+ '/4bit/?hue=45&dyeScope=all#preview'
+ );
+ });
+
+ it('hydrates from the string overload using browser fallbacks in non-browser tests', () => {
+ const pinia = createPinia();
+
+ const schemeStore = hydrateSchemeStoreFromLocation(useSchemeStore(pinia), '?hue=12&dyeScope=all');
+
+ expect(schemeStore.scheme.hue).toBe(12);
+ expect(schemeStore.scheme.dyeScope).toBe('all');
+ });
+
+ it('hydrates from global browser objects when options are omitted', () => {
+ const pinia = createPinia();
+ const originalWindow = globalThis.window;
+ const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, 'window');
+ const history = {
+ state: { from: 'browser' },
+ replaceState: vi.fn(),
+ };
+
+ globalThis.window = {
+ location: {
+ pathname: '/4bit/',
+ search: '',
+ hash: '#preview',
+ },
+ history,
+ localStorage: {
+ getItem: vi.fn(() => '?hue=33'),
+ },
+ };
+
+ try {
+ const schemeStore = hydrateSchemeStoreFromLocation(useSchemeStore(pinia));
+
+ expect(schemeStore.scheme.hue).toBe(33);
+ expect(history.replaceState).toHaveBeenCalledWith(
+ history.state,
+ '',
+ '/4bit/?hue=33#preview'
+ );
+ } finally {
+ if (hadWindow) {
+ globalThis.window = originalWindow;
+ } else {
+ delete globalThis.window;
+ }
+ }
+ });
+
+ it('does not replace history during hydration when the URL already matches', () => {
+ const pinia = createPinia();
+ const history = {
+ state: { from: 'test' },
+ replaceState: vi.fn(),
+ };
+ const location = {
+ pathname: '/4bit/',
+ search: '?hue=12',
+ hash: '#preview',
+ };
+
+ hydrateSchemeStoreFromLocation(useSchemeStore(pinia), {
+ search: '?hue=12',
+ storage: null,
+ location,
+ history,
+ });
+
+ expect(history.replaceState).not.toHaveBeenCalled();
+ });
+
+ it('persists the current scheme search for future visits', () => {
+ const scheme = createDefaultScheme();
+ scheme.hue = 10;
+ scheme.dyeScope = 'all';
+
+ const storage = {
+ setItem: vi.fn(),
+ };
+
+ new SchemeUrlSync({
+ schemeStore: { scheme },
+ location: {
+ pathname: '/4bit/',
+ search: '',
+ hash: '',
+ },
+ history: {
+ state: null,
+ replaceState: vi.fn(),
+ },
+ storage,
+ }).updateLocation(scheme);
+
+ expect(storage.setItem).toHaveBeenCalledWith(
+ SCHEME_STORAGE_KEY,
+ expectedSchemeSearch(scheme)
+ );
+ });
+
+ it('ignores storage errors while keeping URL sync working', () => {
+ const scheme = createDefaultScheme();
+ scheme.hue = 10;
+ const history = {
+ state: null,
+ replaceState: vi.fn(),
+ };
+
+ new SchemeUrlSync({
+ schemeStore: { scheme },
+ location: {
+ pathname: '/4bit/',
+ search: '',
+ hash: '',
+ },
+ history,
+ storage: {
+ setItem: vi.fn(() => {
+ throw new Error('quota exceeded');
+ }),
+ },
+ }).updateLocation(scheme);
+
+ expect(history.replaceState).toHaveBeenCalledWith(
+ history.state,
+ '',
+ `/4bit/${expectedSchemeSearch(scheme)}${expectedMinttyHash(scheme)}`
+ );
+ });
+
+ it('does not replace history when the current URL already matches the scheme', () => {
+ const scheme = createDefaultScheme();
+ scheme.hue = 10;
+ const hash = expectedMinttyHash(scheme);
+ const history = {
+ state: null,
+ replaceState: vi.fn(),
+ };
+
+ new SchemeUrlSync({
+ schemeStore: { scheme },
+ location: {
+ pathname: '/4bit/',
+ search: expectedSchemeSearch(scheme),
+ hash,
+ },
+ history,
+ storage: null,
+ }).updateLocation(scheme);
+
+ expect(history.replaceState).not.toHaveBeenCalled();
+ });
+
+ it('adds the mintty drag payload for the default scheme when no extra params are present', () => {
+ const scheme = createDefaultScheme();
+ const history = {
+ state: null,
+ replaceState: vi.fn(),
+ };
+
+ new SchemeUrlSync({
+ schemeStore: { scheme },
+ location: {
+ pathname: '/4bit/',
+ search: '',
+ hash: '',
+ },
+ history,
+ storage: null,
+ }).updateLocation(scheme);
+
+ expect(history.replaceState).toHaveBeenCalledWith(
+ history.state,
+ '',
+ `/4bit/${expectedSchemeSearch(scheme)}${expectedMinttyHash(scheme)}`
+ );
+ });
+});
diff --git a/tests/infrastructure/serialization/macos-terminal-internals.test.js b/tests/infrastructure/serialization/macos-terminal-internals.test.js
new file mode 100644
index 0000000..a3e5c7b
--- /dev/null
+++ b/tests/infrastructure/serialization/macos-terminal-internals.test.js
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import Color from 'color';
+import { bytesToAscii } from '../../../src/infrastructure/serialization/scheme-exports/macos-terminal/lib/byte-strings';
+import { BinaryPlistData, BinaryPlistUid, encodeBinaryPlist } from '../../../src/infrastructure/serialization/scheme-exports/macos-terminal/lib/binary-plist';
+import { encodeNsColorArchive, nsColorRgbString } from '../../../src/infrastructure/serialization/scheme-exports/macos-terminal/lib/ns-color-archive';
+
+describe('macOS Terminal serialization internals', () => {
+ it('writes the binary plist header and trailer for a simple dictionary', () => {
+ const bytes = encodeBinaryPlist({
+ name: '4bit',
+ count: 2,
+ payload: new BinaryPlistData(new Uint8Array([1, 2, 3])),
+ ref: new BinaryPlistUid(1),
+ });
+
+ expect(bytesToAscii(bytes.slice(0, 8))).toBe('bplist00');
+ expect(bytes.length).toBeGreaterThan(40);
+ expect(bytes[bytes.length - 32 + 6]).toBeGreaterThan(0);
+ expect(bytes[bytes.length - 32 + 7]).toBeGreaterThan(0);
+ });
+
+ it('formats NSColor RGB components for Terminal.app archives', () => {
+ expect(nsColorRgbString(Color('#000000'))).toBe('0 0 0\0');
+ expect(nsColorRgbString(Color('#FFFFFF'))).toBe('1 1 1\0');
+ expect(nsColorRgbString(Color('#990000'))).toBe('0.6000000238 0 0\0');
+ expect(nsColorRgbString(Color('#E5E5E5'))).toBe('0.8980392218 0.8980392218 0.8980392218\0');
+ });
+
+ it('encodes NSColor archives as binary plists', () => {
+ const bytes = encodeNsColorArchive(Color('#000000'));
+
+ expect(bytesToAscii(bytes.slice(0, 8))).toBe('bplist00');
+ expect(bytesToAscii(bytes)).toContain('NSKeyedArchiver');
+ expect(bytesToAscii(bytes)).toContain('NSColor');
+ expect(bytesToAscii(bytes)).toContain('0 0 0\0');
+ });
+});
diff --git a/tests/infrastructure/serialization/scheme-exporters.test.js b/tests/infrastructure/serialization/scheme-exporters.test.js
new file mode 100644
index 0000000..e860cfa
--- /dev/null
+++ b/tests/infrastructure/serialization/scheme-exporters.test.js
@@ -0,0 +1,310 @@
+import { describe, expect, it, vi } from 'vitest';
+import Color from 'color';
+import { bgrHex } from '../../../src/infrastructure/serialization/scheme-exports/conemu';
+import {
+ SCHEME_DOWNLOADS,
+ buildSchemeDownload,
+ canExportScheme,
+} from '../../../src/infrastructure/serialization/scheme-exporters';
+
+function createColors() {
+ return {
+ background: Color('#101010'),
+ foreground: Color('#f0f0f0'),
+ black: Color('#000000'),
+ brightBlack: Color('#808080'),
+ red: Color('#cc0000'),
+ brightRed: Color('#ff5555'),
+ green: Color('#00aa00'),
+ brightGreen: Color('#55ff55'),
+ yellow: Color('#aa5500'),
+ brightYellow: Color('#ffff55'),
+ blue: Color('#0000aa'),
+ brightBlue: Color('#5555ff'),
+ magenta: Color('#aa00aa'),
+ brightMagenta: Color('#ff55ff'),
+ cyan: Color('#00aaaa'),
+ brightCyan: Color('#55ffff'),
+ white: Color('#aaaaaa'),
+ brightWhite: Color('#ffffff'),
+ };
+}
+
+function bytesFromBase64(value) {
+ return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
+}
+
+function bytesToAscii(bytes) {
+ return String.fromCharCode(...bytes);
+}
+
+function dataForKey(plist, key) {
+ const match = plist.match(new RegExp(`${key} \\n\\t([^<]+) `));
+
+ return match?.[1];
+}
+
+describe('SchemeExporters', () => {
+ it('detects whether a full exportable color set is present', () => {
+ const colors = createColors();
+
+ expect(canExportScheme(colors)).toBe(true);
+ expect(canExportScheme({
+ ...colors,
+ brightCyan: null,
+ })).toBe(false);
+ });
+
+ it('builds every declared export format as a non-empty blob', async () => {
+ const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1234567890);
+ const colors = createColors();
+
+ try {
+ for (const definition of SCHEME_DOWNLOADS) {
+ const blob = buildSchemeDownload(definition.id, colors);
+
+ expect(blob.type).toBe(definition.mimeType);
+ expect((await blob.text()).length).toBeGreaterThan(10);
+ }
+ } finally {
+ dateNowSpy.mockRestore();
+ }
+ });
+
+ it('generates the expected Xresources color slot mapping', async () => {
+ const text = await buildSchemeDownload('xresources', createColors()).text();
+
+ expect(text).toContain('*background: #101010');
+ expect(text).toContain('*foreground: #F0F0F0');
+ expect(text).toContain('*color0: #000000');
+ expect(text).toContain('*color8: #808080');
+ expect(text).toContain('*color1: #CC0000');
+ expect(text).toContain('*color15: #FFFFFF');
+ });
+
+ it('generates an iTerm2 plist with the xml mime type', async () => {
+ const blob = buildSchemeDownload('iTerm2', createColors());
+ const text = await blob.text();
+
+ expect(blob.type).toBe('application/xml;charset=utf-8');
+ expect(text.startsWith('')).toBe(true);
+ expect(text).toContain('Background Color ');
+ expect(text).toContain('Ansi 0 Color ');
+ expect(text).toContain('Ansi 15 Color ');
+ });
+
+ it('converts RGB color hex to ConEmu BGR dword hex', () => {
+ expect(bgrHex(Color('#112233'))).toBe('332211');
+ });
+
+ it('generates a ConEmu palette XML fragment', async () => {
+ const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1519084800000);
+
+ try {
+ const blob = buildSchemeDownload('conEmu', createColors());
+ const text = await blob.text();
+
+ expect(blob.type).toBe('application/xml;charset=utf-8');
+ expect(text).toContain(' ');
+ expect(text).toContain(' ');
+ expect(text).toContain(' ');
+ expect(text).toContain(' ');
+ expect(text).toContain(' ');
+ expect(text).toMatchInlineSnapshot(`
+ "
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "
+ `);
+ } finally {
+ dateNowSpy.mockRestore();
+ }
+ });
+
+ it('generates KiTTY portable session color lines matching PuTTY colors', async () => {
+ const text = await buildSchemeDownload('kitty', createColors()).text();
+ const lines = text.trimEnd().split('\n');
+
+ expect(lines).toHaveLength(22);
+ expect(lines[0]).toBe('Colour0\\240,240,240\\');
+ expect(lines[1]).toBe('Colour1\\240,240,240\\');
+ expect(lines[2]).toBe('Colour2\\16,16,16\\');
+ expect(lines[5]).toBe('Colour5\\240,240,240\\');
+ expect(lines[6]).toBe('Colour6\\0,0,0\\');
+ expect(lines[7]).toBe('Colour7\\128,128,128\\');
+ expect(lines[21]).toBe('Colour21\\255,255,255\\');
+ expect(text).not.toContain('[KiTTY]');
+ expect(text).not.toContain('=');
+
+ lines.forEach((line, index) => {
+ expect(line).toMatch(new RegExp(`^Colour${index}\\\\\\d+,\\d+,\\d+\\\\$`));
+ });
+ });
+
+ it('generates a macOS Terminal.app profile with archived color data', async () => {
+ const blob = buildSchemeDownload('macosTerminal', createColors());
+ const text = await blob.text();
+ const colorKeys = [
+ 'ANSIBlackColor',
+ 'ANSIRedColor',
+ 'ANSIGreenColor',
+ 'ANSIYellowColor',
+ 'ANSIBlueColor',
+ 'ANSIMagentaColor',
+ 'ANSICyanColor',
+ 'ANSIWhiteColor',
+ 'ANSIBrightBlackColor',
+ 'ANSIBrightRedColor',
+ 'ANSIBrightGreenColor',
+ 'ANSIBrightYellowColor',
+ 'ANSIBrightBlueColor',
+ 'ANSIBrightMagentaColor',
+ 'ANSIBrightCyanColor',
+ 'ANSIBrightWhiteColor',
+ 'BackgroundColor',
+ 'TextColor',
+ 'TextBoldColor',
+ 'CursorColor',
+ 'SelectionColor',
+ ];
+
+ expect(blob.type).toBe('application/x-plist;charset=utf-8');
+ expect(text.startsWith('')).toBe(true);
+ expect(text).toContain('ProfileCurrentVersion ');
+ expect(text).toContain('2.04 ');
+ expect(text).toContain('name ');
+ expect(text).toContain('4bit ');
+ expect(text).toContain('type ');
+ expect(text).toContain('Window Settings ');
+
+ colorKeys.forEach((key) => {
+ const data = dataForKey(text, key);
+
+ expect(data).toBeTruthy();
+ expect(bytesToAscii(bytesFromBase64(data).slice(0, 8))).toBe('bplist00');
+ });
+ });
+
+ it('generates a GNOME Terminal dconf script for the default profile', async () => {
+ const text = await buildSchemeDownload('gnomeTerminal', createColors()).text();
+
+ expect(text).toContain('gsettings get org.gnome.Terminal.ProfilesList default');
+ expect(text).toContain('PROFILE_PATH="/org/gnome/terminal/legacy/profiles:/:${PROFILE_ID}/"');
+ expect(text).toContain('dconf write "${PROFILE_PATH}use-theme-colors" false');
+ expect(text).toContain('dconf write "${PROFILE_PATH}background-color" "\'#101010\'"');
+ expect(text).toContain('dconf write "${PROFILE_PATH}foreground-color" "\'#F0F0F0\'"');
+ expect(text).toContain(
+ 'dconf write "${PROFILE_PATH}palette" "[\'#000000\', \'#CC0000\', \'#00AA00\', \'#AA5500\', \'#0000AA\', \'#AA00AA\', \'#00AAAA\', \'#AAAAAA\', \'#808080\', \'#FF5555\', \'#55FF55\', \'#FFFF55\', \'#5555FF\', \'#FF55FF\', \'#55FFFF\', \'#FFFFFF\']"'
+ );
+ expect(text).not.toContain('gconftool-2');
+ expect(text).not.toContain('/apps/gnome-terminal/profiles/Default');
+ });
+
+ it('generates the terminator palette in normal-then-bright order', async () => {
+ const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(42);
+
+ try {
+ const text = await buildSchemeDownload('terminator', createColors()).text();
+
+ expect(text).toContain('[[4bit-42]]');
+ expect(text).toContain(
+ 'palette = "#000000:#CC0000:#00AA00:#AA5500:#0000AA:#AA00AA:#00AAAA:#AAAAAA:#808080:#FF5555:#55FF55:#FFFF55:#5555FF:#FF55FF:#55FFFF:#FFFFFF"'
+ );
+ } finally {
+ dateNowSpy.mockRestore();
+ }
+ });
+
+ it('generates a Termite colors section', async () => {
+ const text = await buildSchemeDownload('termite', createColors()).text();
+
+ expect(text).toContain('[colors]\n');
+ expect(text).toContain('foreground = #F0F0F0');
+ expect(text).toContain('background = #101010');
+ expect(text).toContain('cursor = #F0F0F0');
+ expect(text).toContain('cursor_foreground = #101010');
+ expect(text).toContain('color0 = #000000');
+ expect(text).toContain('color8 = #808080');
+ expect(text).toContain('color15 = #FFFFFF');
+ });
+
+ it('generates a Windows Terminal JSON scheme', async () => {
+ const blob = buildSchemeDownload('windowsTerminal', createColors());
+ const scheme = JSON.parse(await blob.text());
+ const terminalColorKeys = [
+ 'black',
+ 'red',
+ 'green',
+ 'yellow',
+ 'blue',
+ 'purple',
+ 'cyan',
+ 'white',
+ 'brightBlack',
+ 'brightRed',
+ 'brightGreen',
+ 'brightYellow',
+ 'brightBlue',
+ 'brightPurple',
+ 'brightCyan',
+ 'brightWhite',
+ ];
+
+ expect(blob.type).toBe('application/json;charset=utf-8');
+ expect(scheme.name).toBe('4bit');
+ expect(scheme.background).toBe('#101010');
+ expect(scheme.foreground).toBe('#F0F0F0');
+ expect(scheme.cursorColor).toBe('#F0F0F0');
+ expect(scheme.selectionBackground).toBe('#808080');
+ expect(scheme.purple).toBe('#AA00AA');
+ expect(scheme.brightPurple).toBe('#FF55FF');
+
+ terminalColorKeys.forEach((key) => {
+ expect(scheme[key]).toMatch(/^#[0-9A-F]{6}$/);
+ });
+ });
+
+ it('throws for unknown export formats', () => {
+ expect(() => buildSchemeDownload('unknown-format', createColors())).toThrow(
+ 'Unknown export format: unknown-format'
+ );
+ });
+});
diff --git a/tests/infrastructure/serialization/scheme-exports-shared.test.js b/tests/infrastructure/serialization/scheme-exports-shared.test.js
new file mode 100644
index 0000000..d560a74
--- /dev/null
+++ b/tests/infrastructure/serialization/scheme-exports-shared.test.js
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import {
+ normalColorName,
+ paletteColorNames,
+} from '../../../src/infrastructure/serialization/scheme-exports/shared';
+
+describe('scheme-exports/shared', () => {
+ it('keeps standard color names unchanged', () => {
+ expect(normalColorName('red')).toBe('red');
+ expect(normalColorName('brightBlue')).toBe('blue');
+ });
+
+ it('builds a palette with standard colors before bright colors', () => {
+ expect(paletteColorNames().slice(0, 4)).toEqual(['black', 'red', 'green', 'yellow']);
+ expect(paletteColorNames().slice(-4)).toEqual([
+ 'brightBlue',
+ 'brightMagenta',
+ 'brightCyan',
+ 'brightWhite',
+ ]);
+ });
+});
diff --git a/tests/infrastructure/url/mintty-drag-scheme.test.js b/tests/infrastructure/url/mintty-drag-scheme.test.js
new file mode 100644
index 0000000..1cc2d56
--- /dev/null
+++ b/tests/infrastructure/url/mintty-drag-scheme.test.js
@@ -0,0 +1,106 @@
+import { describe, expect, it } from 'vitest';
+import Color from 'color';
+import {
+ buildMinttyDragSchemeHash,
+ MINTTY_DRAG_SCHEME_COLOR_ORDER,
+ serializeMinttyDragScheme,
+} from '../../../src/infrastructure/url/mintty-drag-scheme';
+
+const EXPECTED_SCHEME_VALUES = [
+ '101010',
+ 'F0F0F0',
+ 'F0F0F0',
+ '000000',
+ 'CC0000',
+ '00AA00',
+ 'AA5500',
+ '0000AA',
+ 'AA00AA',
+ '00AAAA',
+ 'AAAAAA',
+ '808080',
+ 'FF5555',
+ '55FF55',
+ 'FFFF55',
+ '5555FF',
+ 'FF55FF',
+ '55FFFF',
+ 'FFFFFF',
+];
+const EXPECTED_SCHEME = EXPECTED_SCHEME_VALUES.join(':');
+
+function createColors() {
+ return {
+ background: Color('#101010'),
+ foreground: Color('#f0f0f0'),
+ black: Color('#000000'),
+ brightBlack: Color('#808080'),
+ red: Color('#cc0000'),
+ brightRed: Color('#ff5555'),
+ green: Color('#00aa00'),
+ brightGreen: Color('#55ff55'),
+ yellow: Color('#aa5500'),
+ brightYellow: Color('#ffff55'),
+ blue: Color('#0000aa'),
+ brightBlue: Color('#5555ff'),
+ magenta: Color('#aa00aa'),
+ brightMagenta: Color('#ff55ff'),
+ cyan: Color('#00aaaa'),
+ brightCyan: Color('#55ffff'),
+ white: Color('#aaaaaa'),
+ brightWhite: Color('#ffffff'),
+ };
+}
+
+describe('mintty-drag-scheme', () => {
+ it('documents the compact URL color order', () => {
+ expect(MINTTY_DRAG_SCHEME_COLOR_ORDER).toEqual([
+ 'background',
+ 'foreground',
+ 'cursor',
+ 'black',
+ 'red',
+ 'green',
+ 'yellow',
+ 'blue',
+ 'magenta',
+ 'cyan',
+ 'white',
+ 'brightBlack',
+ 'brightRed',
+ 'brightGreen',
+ 'brightYellow',
+ 'brightBlue',
+ 'brightMagenta',
+ 'brightCyan',
+ 'brightWhite',
+ ]);
+ });
+
+ it('serializes the full mintty drag-and-drop scheme as uppercase hex values', () => {
+ const scheme = serializeMinttyDragScheme(createColors());
+
+ expect(scheme).toBe(EXPECTED_SCHEME);
+ });
+
+ it('can use an explicit cursor color when one is available', () => {
+ const scheme = serializeMinttyDragScheme({
+ ...createColors(),
+ cursor: Color('#123456'),
+ });
+
+ expect(scheme.split(':')[2]).toBe('123456');
+ });
+
+ it('builds the hash payload expected by mintty link drag-and-drop', () => {
+ expect(buildMinttyDragSchemeHash(createColors())).toBe(`#?scheme=${EXPECTED_SCHEME}`);
+ });
+
+ it('returns an empty payload until the calculated color set is complete', () => {
+ expect(serializeMinttyDragScheme({
+ ...createColors(),
+ brightWhite: null,
+ })).toBe('');
+ expect(buildMinttyDragSchemeHash(null)).toBe('');
+ });
+});
diff --git a/tests/infrastructure/url/scheme-query.test.js b/tests/infrastructure/url/scheme-query.test.js
new file mode 100644
index 0000000..9bf7feb
--- /dev/null
+++ b/tests/infrastructure/url/scheme-query.test.js
@@ -0,0 +1,226 @@
+import { describe, expect, it } from 'vitest';
+import { degreesForColorMode } from '../../../src/domain/scheme/color-mode';
+import { createDefaultScheme } from '../../../src/domain/scheme/scheme-defaults';
+import {
+ buildSchemeSearch,
+ readSchemeFromSearch,
+} from '../../../src/infrastructure/url/scheme-query';
+
+function compressedParamsFor(scheme) {
+ return new URLSearchParams(buildSchemeSearch(scheme).slice(1));
+}
+
+describe('scheme-query', () => {
+ it('returns the default scheme when the query string is empty', () => {
+ expect(readSchemeFromSearch('')).toEqual(createDefaultScheme());
+ });
+
+ it('hydrates legacy params from defaults and derives preset degrees when omitted', () => {
+ const scheme = readSchemeFromSearch(
+ '?hue=25&colorMode=duotone&hueDistance=15&foreground=custom&customForegroundColor=210,60,70'
+ );
+
+ expect(scheme.hue).toBe(25);
+ expect(scheme.colorMode).toBe('duotone');
+ expect(scheme.hueDistance).toBe(15);
+ expect(scheme.degrees).toEqual(degreesForColorMode('duotone', 15));
+ expect(scheme.foreground).toBe('custom');
+ expect(scheme.customForegroundColor).toEqual({
+ hue: 210,
+ saturation: 60,
+ lightness: 70,
+ });
+ expect(scheme.saturation).toBe(createDefaultScheme().saturation);
+ expect(scheme.dyeScope).toBe(createDefaultScheme().dyeScope);
+ });
+
+ it('accepts legacy hue-set aliases from older shared links', () => {
+ const scheme = readSchemeFromSearch('?hueSet=duo&hueDistance=15');
+
+ expect(scheme.colorMode).toBe('duotone');
+ expect(scheme.degrees).toEqual(degreesForColorMode('duotone', 15));
+ });
+
+ it('serializes legacy aliases back through the compressed s param', () => {
+ const scheme = readSchemeFromSearch('?hueSet=duo&hueDistance=15');
+ const params = compressedParamsFor(scheme);
+
+ expect(params.get('s')).toBeTruthy();
+ expect(params.get('colorMode')).toBeNull();
+ expect(params.get('hueSet')).toBeNull();
+ expect(readSchemeFromSearch(`?${params.toString()}`).colorMode).toBe('duotone');
+ });
+
+ it('serializes the default scheme into a complete compressed s param', () => {
+ const params = compressedParamsFor(createDefaultScheme());
+
+ expect(params.get('s')).toMatch(/^2[A-Za-z0-9_-]+$/);
+ expect(params.get('hue')).toBeNull();
+ });
+
+ it('writes only the compressed setting param for preset schemes', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.hue = 12;
+ scheme.colorMode = 'duotone';
+ scheme.hueDistance = 18;
+ scheme.degrees = degreesForColorMode('duotone', 18);
+
+ const params = compressedParamsFor(scheme);
+
+ expect(params.get('s')).toBeTruthy();
+ expect(params.get('hue')).toBeNull();
+ expect(params.get('colorMode')).toBeNull();
+ expect(params.get('hueDistance')).toBeNull();
+ expect(readSchemeFromSearch(buildSchemeSearch(scheme))).toEqual(scheme);
+ });
+
+ it('roundtrips a custom scheme through the compressed query string', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.hue = 12;
+ scheme.colorMode = 'custom';
+ scheme.hueDistance = 18;
+ scheme.degrees = [0, 23, 111, 187, 244, 301];
+ scheme.saturation = 63;
+ scheme.saturationRange = 7;
+ scheme.normalChromaticLightness = 48;
+ scheme.brightChromaticLightness = 72;
+ scheme.lightnessRange = 4;
+ scheme.normalBlackLightness = 2;
+ scheme.brightBlackLightness = 15;
+ scheme.normalWhiteLightness = 86;
+ scheme.brightWhiteLightness = 99;
+ scheme.dyeScope = 'color';
+ scheme.dyeColor = {
+ hue: 120,
+ saturation: 70,
+ lightness: 55,
+ alpha: 0.5,
+ };
+ scheme.background = 'custom';
+ scheme.customBackgroundColor = {
+ hue: 40,
+ saturation: 30,
+ lightness: 15,
+ };
+ scheme.foreground = 'bright_white';
+ scheme.customForegroundColor = {
+ hue: 260,
+ saturation: 20,
+ lightness: 80,
+ };
+
+ expect(readSchemeFromSearch(buildSchemeSearch(scheme))).toEqual(scheme);
+ });
+
+ it('serializes quantized numeric values in the packed payload without losing precision', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.normalChromaticLightness = 127 / 2.56;
+ scheme.brightChromaticLightness = 191 / 2.56;
+ scheme.normalBlackLightness = 1 / 2.56;
+ scheme.brightBlackLightness = 32 / 2.56;
+ scheme.dyeColor = {
+ hue: 180,
+ saturation: 50.1,
+ lightness: 50.25,
+ alpha: 0.25,
+ };
+
+ const settings = compressedParamsFor(scheme).get('s');
+
+ expect(settings).toMatch(/^2[A-Za-z0-9_-]+$/);
+ expect(settings.length).toBeLessThan(55);
+ expect(readSchemeFromSearch(buildSchemeSearch(scheme))).toEqual(scheme);
+ });
+
+ it('serializes negative near-zero picker values as 0 instead of -0', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.dyeColor = {
+ hue: 180,
+ saturation: -0.0000000001,
+ lightness: 50,
+ alpha: 0.25,
+ };
+
+ const settings = compressedParamsFor(scheme).get('s');
+
+ expect(settings).toMatch(/^2[A-Za-z0-9_-]+$/);
+ expect(readSchemeFromSearch(buildSchemeSearch(scheme)).dyeColor.saturation).toBe(0);
+ });
+
+ it('serializes non-finite values verbatim instead of crashing quantization', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.hue = Number.POSITIVE_INFINITY;
+
+ expect(compressedParamsFor(scheme).get('s')).toContain('Infinity');
+ });
+
+ it('keeps the packed setting payload URL-safe', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.normalChromaticLightness = 120 / 2.56;
+ scheme.brightChromaticLightness = 192 / 2.56;
+ scheme.normalBlackLightness = 1 / 2.56;
+ scheme.brightBlackLightness = 32 / 2.56;
+
+ const search = buildSchemeSearch(scheme);
+
+ expect(search).toMatch(/^\?s=2[A-Za-z0-9_-]+$/);
+ expect(search).not.toContain('%2C');
+ });
+
+ it('keeps explicit degrees when the declared color mode no longer matches a preset', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.colorMode = 'duotone';
+ scheme.hueDistance = 18;
+ scheme.degrees = [0, 1, 2, 3, 4, 5];
+
+ const params = compressedParamsFor(scheme);
+ const decoded = readSchemeFromSearch(`?${params.toString()}`);
+
+ expect(params.get('degrees')).toBeNull();
+ expect(decoded.colorMode).toBe('custom');
+ expect(decoded.degrees).toEqual(scheme.degrees);
+ });
+
+ it('prefers compressed settings over legacy params when both are present', () => {
+ const scheme = createDefaultScheme();
+ scheme.hue = 44;
+
+ const compressedSearch = buildSchemeSearch(scheme);
+
+ expect(readSchemeFromSearch(`${compressedSearch}&hue=12`).hue).toBe(44);
+ });
+
+ it('falls back to legacy params when the compressed payload is malformed', () => {
+ const scheme = readSchemeFromSearch('?s=invalid&hue=12&dyeScope=all');
+
+ expect(scheme.hue).toBe(12);
+ expect(scheme.dyeScope).toBe('all');
+ });
+
+ it('ignores malformed legacy values and falls back to defaults for those fields', () => {
+ const scheme = readSchemeFromSearch(
+ '?hue=oops°rees=1,2,3&dyeColor=1,2,3&foreground=invalid&customForegroundColor=4,5'
+ );
+ const defaults = createDefaultScheme();
+
+ expect(scheme.hue).toBe(defaults.hue);
+ expect(scheme.degrees).toEqual(defaults.degrees);
+ expect(scheme.dyeColor).toEqual(defaults.dyeColor);
+ expect(scheme.foreground).toBe(defaults.foreground);
+ expect(scheme.customForegroundColor).toEqual(defaults.customForegroundColor);
+ });
+
+ it('clamps advanced ranges from shared links to the same limits as the UI', () => {
+ const scheme = readSchemeFromSearch('?saturationRange=999&lightnessRange=999');
+
+ expect(scheme.saturationRange).toBe(50);
+ expect(scheme.lightnessRange).toBe(30);
+ });
+});
diff --git a/tests/infrastructure/url/scheme-settings.test.js b/tests/infrastructure/url/scheme-settings.test.js
new file mode 100644
index 0000000..1394715
--- /dev/null
+++ b/tests/infrastructure/url/scheme-settings.test.js
@@ -0,0 +1,119 @@
+import { describe, expect, it } from 'vitest';
+import { degreesForColorMode } from '../../../src/domain/scheme/color-mode';
+import { createDefaultScheme } from '../../../src/domain/scheme/scheme-defaults';
+import {
+ decodeSchemeUrlSettings,
+ encodeSchemeUrlSettings,
+} from '../../../src/infrastructure/url/scheme-settings';
+
+const DEFAULT_ENCODED_SCHEME = [
+ '1',
+ '-15',
+ 'h',
+ '0',
+ '0,60,120,180,240,300',
+ '50',
+ '0',
+ '50,75',
+ '0',
+ '0,12.5',
+ '87.5,100',
+ 'n',
+ '180,50,50,0.25',
+ 'k',
+ '180,50,10',
+ 'w',
+ '180,50,90',
+].join('~');
+
+function createCustomScheme() {
+ const scheme = createDefaultScheme();
+
+ scheme.hue = 12;
+ scheme.colorMode = 'custom';
+ scheme.hueDistance = 18;
+ scheme.degrees = [0, 23, 111, 187, 244, 301];
+ scheme.saturation = 63;
+ scheme.saturationRange = 7;
+ scheme.normalChromaticLightness = 48;
+ scheme.brightChromaticLightness = 72;
+ scheme.lightnessRange = 4;
+ scheme.normalBlackLightness = 2;
+ scheme.brightBlackLightness = 15;
+ scheme.normalWhiteLightness = 86;
+ scheme.brightWhiteLightness = 99;
+ scheme.dyeScope = 'color';
+ scheme.dyeColor = {
+ hue: 120,
+ saturation: 70,
+ lightness: 55,
+ alpha: 0.5,
+ };
+ scheme.background = 'custom';
+ scheme.customBackgroundColor = {
+ hue: 40,
+ saturation: 30,
+ lightness: 15,
+ };
+ scheme.foreground = 'bright_white';
+ scheme.customForegroundColor = {
+ hue: 260,
+ saturation: 20,
+ lightness: 80,
+ };
+
+ return scheme;
+}
+
+describe('scheme-url-settings', () => {
+ it('encodes the complete default scheme in the compact packed URL format', () => {
+ const encoded = encodeSchemeUrlSettings(createDefaultScheme());
+
+ expect(encoded).toMatch(/^2[A-Za-z0-9_-]+$/);
+ expect(encoded.length).toBeLessThan(55);
+ expect(decodeSchemeUrlSettings(encoded)).toEqual(createDefaultScheme());
+ });
+
+ it('keeps support for the text v1 URL payload', () => {
+ expect(decodeSchemeUrlSettings(DEFAULT_ENCODED_SCHEME)).toEqual(createDefaultScheme());
+ });
+
+ it('roundtrips every setting without encoded color output', () => {
+ const scheme = createCustomScheme();
+
+ expect(decodeSchemeUrlSettings(encodeSchemeUrlSettings(scheme))).toEqual(scheme);
+ });
+
+ it('canonicalizes stale color mode values when explicit degrees are custom', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.colorMode = 'duotone';
+ scheme.hueDistance = 18;
+ scheme.degrees = [0, 1, 2, 3, 4, 5];
+
+ const decoded = decodeSchemeUrlSettings(encodeSchemeUrlSettings(scheme));
+
+ expect(decoded.colorMode).toBe('custom');
+ expect(decoded.degrees).toEqual(scheme.degrees);
+ });
+
+ it('keeps preset modes canonical when the degrees match the preset', () => {
+ const scheme = createDefaultScheme();
+
+ scheme.colorMode = 'duotone';
+ scheme.hueDistance = 18;
+ scheme.degrees = degreesForColorMode('duotone', 18);
+
+ const decoded = decodeSchemeUrlSettings(encodeSchemeUrlSettings(scheme));
+
+ expect(decoded.colorMode).toBe('duotone');
+ expect(decoded.degrees).toEqual(degreesForColorMode('duotone', 18));
+ });
+
+ it('rejects malformed or unsupported payloads', () => {
+ expect(decodeSchemeUrlSettings('')).toBeNull();
+ expect(decodeSchemeUrlSettings('2~unsupported')).toBeNull();
+ expect(decodeSchemeUrlSettings('1~too~short')).toBeNull();
+ expect(decodeSchemeUrlSettings(DEFAULT_ENCODED_SCHEME.replace('50', 'oops'))).toBeNull();
+ });
+});
diff --git a/tests/infrastructure/url/share-urls.test.js b/tests/infrastructure/url/share-urls.test.js
new file mode 100644
index 0000000..47125a4
--- /dev/null
+++ b/tests/infrastructure/url/share-urls.test.js
@@ -0,0 +1,161 @@
+import { describe, expect, it } from 'vitest';
+import Color from 'color';
+import { createDefaultScheme } from '../../../src/domain/scheme/scheme-defaults';
+import {
+ buildFacebookShareHref,
+ buildLinkedInShareHref,
+ buildShareUrl,
+ buildTwitterShareHref,
+ defaultShareBaseUrl,
+ SHARE_TEXT,
+} from '../../../src/infrastructure/url/share-urls';
+import { buildSchemeSearch } from '../../../src/infrastructure/url/scheme-query';
+
+const MINTTY_DRAG_SCHEME = [
+ '101010',
+ 'F0F0F0',
+ 'F0F0F0',
+ '000000',
+ 'CC0000',
+ '00AA00',
+ 'AA5500',
+ '0000AA',
+ 'AA00AA',
+ '00AAAA',
+ 'AAAAAA',
+ '808080',
+ 'FF5555',
+ '55FF55',
+ 'FFFF55',
+ '5555FF',
+ 'FF55FF',
+ '55FFFF',
+ 'FFFFFF',
+].join(':');
+const MINTTY_DRAG_HASH = `#?scheme=${MINTTY_DRAG_SCHEME}`;
+
+function createColors() {
+ return {
+ background: Color('#101010'),
+ foreground: Color('#f0f0f0'),
+ black: Color('#000000'),
+ brightBlack: Color('#808080'),
+ red: Color('#cc0000'),
+ brightRed: Color('#ff5555'),
+ green: Color('#00aa00'),
+ brightGreen: Color('#55ff55'),
+ yellow: Color('#aa5500'),
+ brightYellow: Color('#ffff55'),
+ blue: Color('#0000aa'),
+ brightBlue: Color('#5555ff'),
+ magenta: Color('#aa00aa'),
+ brightMagenta: Color('#ff55ff'),
+ cyan: Color('#00aaaa'),
+ brightCyan: Color('#55ffff'),
+ white: Color('#aaaaaa'),
+ brightWhite: Color('#ffffff'),
+ };
+}
+
+describe('share-urls', () => {
+ it('builds a share URL with compressed settings and mintty drag payload', () => {
+ const scheme = createDefaultScheme();
+ scheme.hue = 12;
+ scheme.colorMode = 'duotone';
+ scheme.hueDistance = 18;
+ scheme.degrees = [0, 18, 180, 198, 162, 342];
+
+ expect(buildShareUrl({
+ scheme,
+ colors: createColors(),
+ location: {
+ origin: 'https://ciembor.github.io',
+ pathname: '/4bit/',
+ },
+ })).toBe(
+ `https://ciembor.github.io/4bit/${buildSchemeSearch(scheme)}${MINTTY_DRAG_HASH}`
+ );
+ });
+
+ it('builds a twitter intent link with compressed settings but without the mintty scheme hash', () => {
+ const scheme = createDefaultScheme();
+ scheme.dyeScope = 'all';
+
+ const href = buildTwitterShareHref({
+ scheme,
+ colors: createColors(),
+ location: {
+ origin: 'https://ciembor.github.io',
+ pathname: '/4bit/',
+ },
+ });
+ const url = new URL(href);
+
+ expect(`${url.origin}${url.pathname}`).toBe('https://twitter.com/intent/tweet');
+ expect(url.searchParams.get('text')).toBe(SHARE_TEXT);
+ expect(url.searchParams.get('url')).toBe(
+ `https://ciembor.github.io/4bit/${buildSchemeSearch(scheme)}`
+ );
+ expect(url.searchParams.get('via')).toBe('ciembor');
+ });
+
+ it('falls back to the public share URL when current location is local', () => {
+ const scheme = createDefaultScheme();
+ scheme.dyeScope = 'all';
+
+ expect(buildShareUrl({
+ scheme,
+ location: {
+ origin: 'http://localhost:5173',
+ pathname: '/',
+ },
+ })).toBe(`https://ciembor.github.io/4bit/${buildSchemeSearch(scheme)}`);
+ });
+
+ it('builds a linkedin share link with the encoded URL', () => {
+ const scheme = createDefaultScheme();
+ scheme.dyeScope = 'all';
+
+ const href = buildLinkedInShareHref({
+ scheme,
+ colors: createColors(),
+ location: {
+ origin: 'https://ciembor.github.io',
+ pathname: '/4bit/',
+ },
+ });
+ const url = new URL(href);
+
+ expect(`${url.origin}${url.pathname}`).toBe('https://www.linkedin.com/sharing/share-offsite/');
+ expect(url.searchParams.get('url')).toBe(
+ `https://ciembor.github.io/4bit/${buildSchemeSearch(scheme)}${MINTTY_DRAG_HASH}`
+ );
+ });
+
+ it('builds a facebook share link with the encoded URL', () => {
+ const scheme = createDefaultScheme();
+ scheme.dyeScope = 'all';
+
+ const href = buildFacebookShareHref({
+ scheme,
+ colors: createColors(),
+ location: {
+ origin: 'https://ciembor.github.io',
+ pathname: '/4bit/',
+ },
+ });
+ const url = new URL(href);
+
+ expect(`${url.origin}${url.pathname}`).toBe('https://www.facebook.com/sharer/sharer.php');
+ expect(url.searchParams.get('u')).toBe(
+ `https://ciembor.github.io/4bit/${buildSchemeSearch(scheme)}${MINTTY_DRAG_HASH}`
+ );
+ });
+
+ it('uses the production share URL defaults when location is unavailable', () => {
+ const scheme = createDefaultScheme();
+
+ expect(buildShareUrl({ scheme })).toBe(`https://ciembor.github.io/4bit/${buildSchemeSearch(scheme)}`);
+ expect(defaultShareBaseUrl()).toBe('https://ciembor.github.io/4bit/');
+ });
+});
diff --git a/tests/presentation/editor-page/calculated-scheme-sync.test.js b/tests/presentation/editor-page/calculated-scheme-sync.test.js
new file mode 100644
index 0000000..b2dd939
--- /dev/null
+++ b/tests/presentation/editor-page/calculated-scheme-sync.test.js
@@ -0,0 +1,96 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { createPinia, setActivePinia } from 'pinia';
+import { calculateSchemeColors } from '../../../src/domain/scheme/color-scheme-calculator';
+import { createDefaultScheme } from '../../../src/domain/scheme/scheme-defaults';
+import { useCalculatedSchemeStore } from '../../../src/presentation/shared/stores/calculated-scheme';
+import { useSchemeStore } from '../../../src/presentation/shared/stores/scheme';
+
+const { watchMock } = vi.hoisted(() => ({
+ watchMock: vi.fn(),
+}));
+
+vi.mock('vue', () => ({
+ watch: watchMock,
+}));
+
+const { default: CalculatedSchemeSync } = await import(
+ '../../../src/presentation/editor-page/calculated-scheme-sync'
+);
+
+describe('CalculatedSchemeSync', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ watchMock.mockReset();
+ });
+
+ it('calculates the scheme immediately when constructed', () => {
+ let stopCalls = 0;
+
+ watchMock.mockImplementation((source, callback, options) => {
+ if (options?.immediate) {
+ callback(source());
+ }
+
+ return () => {
+ stopCalls += 1;
+ };
+ });
+
+ const schemeStore = useSchemeStore();
+ const calculatedSchemeStore = useCalculatedSchemeStore();
+
+ const sync = new CalculatedSchemeSync();
+
+ expect(watchMock).toHaveBeenCalledTimes(1);
+ expect(calculatedSchemeStore.calculatedScheme).toEqual(
+ calculateSchemeColors(schemeStore.scheme)
+ );
+
+ sync.stop();
+ expect(stopCalls).toBe(1);
+ expect(sync.stopWatcher).toBeNull();
+ });
+
+ it('restarts with a custom watcher and stops the previous watcher first', () => {
+ const previousStop = vi.fn();
+ const nextStop = vi.fn();
+
+ watchMock.mockReturnValue(previousStop);
+
+ const sync = new CalculatedSchemeSync({
+ schemeStore: { scheme: createDefaultScheme() },
+ calculatedSchemeStore: { calculatedScheme: null },
+ });
+
+ const customWatch = vi.fn(() => nextStop);
+ const returnedStop = sync.start(customWatch);
+
+ expect(previousStop).toHaveBeenCalledTimes(1);
+ expect(customWatch).toHaveBeenCalledTimes(1);
+ expect(returnedStop).toBe(nextStop);
+
+ sync.stop();
+ expect(nextStop).toHaveBeenCalledTimes(1);
+ });
+
+ it('ignores null schemes when the watcher fires', () => {
+ const calculatedSchemeStore = {
+ calculatedScheme: { untouched: true },
+ };
+
+ watchMock.mockImplementation((source, callback, options) => {
+ if (options?.immediate) {
+ callback(source());
+ }
+
+ return vi.fn();
+ });
+
+ new CalculatedSchemeSync({
+ schemeStore: { scheme: null },
+ calculatedSchemeStore,
+ });
+
+ expect(calculatedSchemeStore.calculatedScheme).toEqual({ untouched: true });
+ });
+});
diff --git a/tests/presentation/editor-page/scheme-store.test.js b/tests/presentation/editor-page/scheme-store.test.js
new file mode 100644
index 0000000..95eb084
--- /dev/null
+++ b/tests/presentation/editor-page/scheme-store.test.js
@@ -0,0 +1,109 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { createPinia, setActivePinia } from 'pinia';
+import { degreesForColorMode } from '../../../src/domain/scheme/color-mode';
+import { createDefaultScheme } from '../../../src/domain/scheme/scheme-defaults';
+import { useSchemeStore } from '../../../src/presentation/shared/stores/scheme';
+
+describe('Scheme store', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ });
+
+ it('resets the full scheme back to defaults', () => {
+ const schemeStore = useSchemeStore();
+
+ schemeStore.scheme.hue = 12;
+ schemeStore.scheme.degrees = [1, 2, 3, 4, 5, 6];
+ schemeStore.scheme.dyeColor.hue = 210;
+ schemeStore.scheme.customBackgroundColor.lightness = 35;
+
+ schemeStore.resetScheme();
+
+ const defaults = createDefaultScheme();
+ expect(schemeStore.scheme).toEqual(defaults);
+ expect(schemeStore.scheme).not.toBe(defaults);
+ expect(schemeStore.scheme.degrees).not.toBe(defaults.degrees);
+ expect(schemeStore.scheme.dyeColor).not.toBe(defaults.dyeColor);
+ expect(schemeStore.scheme.customBackgroundColor).not.toBe(defaults.customBackgroundColor);
+ });
+
+ it('keeps preset color mode invariants inside store actions', () => {
+ const schemeStore = useSchemeStore();
+
+ schemeStore.setHue(210);
+ schemeStore.setHueDistance(18);
+
+ expect(schemeStore.setColorMode('duotone')).toBe(true);
+ expect(schemeStore.scheme.hue).toBe(30);
+ expect(schemeStore.scheme.colorMode).toBe('duotone');
+ expect(schemeStore.scheme.degrees).toEqual(degreesForColorMode('duotone', 18));
+ });
+
+ it('clamps advanced ranges when updating or replacing the scheme', () => {
+ const schemeStore = useSchemeStore();
+
+ schemeStore.setSaturationRange(999);
+ schemeStore.setLightnessRange(999);
+
+ expect(schemeStore.scheme.saturationRange).toBe(50);
+ expect(schemeStore.scheme.lightnessRange).toBe(30);
+
+ schemeStore.replaceScheme({
+ ...createDefaultScheme(),
+ saturationRange: 999,
+ lightnessRange: 999,
+ });
+
+ expect(schemeStore.scheme.saturationRange).toBe(50);
+ expect(schemeStore.scheme.lightnessRange).toBe(30);
+ });
+
+ it('updates the remaining scheme fields through dedicated actions', () => {
+ const schemeStore = useSchemeStore();
+ const dyeColor = {
+ hue: 210,
+ saturation: 65,
+ lightness: 55,
+ alpha: 0.4,
+ };
+ const backgroundColor = {
+ hue: 20,
+ saturation: 15,
+ lightness: 12,
+ };
+ const foregroundColor = {
+ hue: 240,
+ saturation: 25,
+ lightness: 85,
+ };
+
+ schemeStore.setSaturation(63);
+ schemeStore.setChromaticLightnessRange(48, 72);
+ schemeStore.setBlackLightnessRange(3, 14);
+ schemeStore.setWhiteLightnessRange(86, 99);
+ schemeStore.setDyeColor(dyeColor);
+ schemeStore.setDyeScope('all');
+ schemeStore.setBackgroundColor(backgroundColor);
+ schemeStore.setBackgroundMode('custom');
+ schemeStore.setForegroundColor(foregroundColor);
+ schemeStore.setForegroundMode('bright_white');
+
+ expect(schemeStore.scheme.saturation).toBe(63);
+ expect(schemeStore.scheme.normalChromaticLightness).toBe(48);
+ expect(schemeStore.scheme.brightChromaticLightness).toBe(72);
+ expect(schemeStore.scheme.normalBlackLightness).toBe(3);
+ expect(schemeStore.scheme.brightBlackLightness).toBe(14);
+ expect(schemeStore.scheme.normalWhiteLightness).toBe(86);
+ expect(schemeStore.scheme.brightWhiteLightness).toBe(99);
+ expect(schemeStore.scheme.dyeScope).toBe('all');
+ expect(schemeStore.scheme.background).toBe('custom');
+ expect(schemeStore.scheme.foreground).toBe('bright_white');
+
+ expect(schemeStore.scheme.dyeColor).toEqual(dyeColor);
+ expect(schemeStore.scheme.dyeColor).not.toBe(dyeColor);
+ expect(schemeStore.scheme.customBackgroundColor).toEqual(backgroundColor);
+ expect(schemeStore.scheme.customBackgroundColor).not.toBe(backgroundColor);
+ expect(schemeStore.scheme.customForegroundColor).toEqual(foregroundColor);
+ expect(schemeStore.scheme.customForegroundColor).not.toBe(foregroundColor);
+ });
+});
diff --git a/tests/presentation/editor-page/terminal-view-theme.test.js b/tests/presentation/editor-page/terminal-view-theme.test.js
new file mode 100644
index 0000000..5d58079
--- /dev/null
+++ b/tests/presentation/editor-page/terminal-view-theme.test.js
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest';
+import Color from 'color';
+import { terminalViewThemeFromScheme } from '../../../src/presentation/editor-page/terminal-preview/terminal-view-theme';
+
+function createColors() {
+ return {
+ background: Color('#101010'),
+ foreground: Color('#f0f0f0'),
+ black: Color('#000000'),
+ brightBlack: Color('#808080'),
+ red: Color('#cc0000'),
+ brightRed: Color('#ff5555'),
+ green: Color('#00aa00'),
+ brightGreen: Color('#55ff55'),
+ yellow: Color('#aa5500'),
+ brightYellow: Color('#ffff55'),
+ blue: Color('#0000aa'),
+ brightBlue: Color('#5555ff'),
+ magenta: Color('#aa00aa'),
+ brightMagenta: Color('#ff55ff'),
+ cyan: Color('#00aaaa'),
+ brightCyan: Color('#55ffff'),
+ white: Color('#aaaaaa'),
+ brightWhite: Color('#ffffff'),
+ };
+}
+
+describe('terminalViewThemeFromScheme', () => {
+ it('maps the 4bit color scheme to a terminal view theme', () => {
+ expect(terminalViewThemeFromScheme(createColors())).toEqual({
+ background: '#101010',
+ foreground: '#F0F0F0',
+ cursor: '#F0F0F0',
+ selectionBackground: '#808080',
+ black: '#000000',
+ red: '#CC0000',
+ green: '#00AA00',
+ yellow: '#AA5500',
+ blue: '#0000AA',
+ magenta: '#AA00AA',
+ cyan: '#00AAAA',
+ white: '#AAAAAA',
+ brightBlack: '#808080',
+ brightRed: '#FF5555',
+ brightGreen: '#55FF55',
+ brightYellow: '#FFFF55',
+ brightBlue: '#5555FF',
+ brightMagenta: '#FF55FF',
+ brightCyan: '#55FFFF',
+ brightWhite: '#FFFFFF',
+ });
+ });
+});
diff --git a/tests/presentation/editor-page/terminal-view.test.js b/tests/presentation/editor-page/terminal-view.test.js
new file mode 100644
index 0000000..e404091
--- /dev/null
+++ b/tests/presentation/editor-page/terminal-view.test.js
@@ -0,0 +1,259 @@
+import { describe, expect, it, vi } from 'vitest';
+import { createTerminalView } from '../../../src/presentation/editor-page/terminal-preview/terminal-view';
+
+function createTerminalClass() {
+ const instances = [];
+
+ class TerminalClass {
+ constructor(options) {
+ this.options = options;
+ this.open = vi.fn();
+ this.focus = vi.fn();
+ this.reset = vi.fn();
+ this.write = vi.fn();
+ this.dispose = vi.fn();
+ this.onData = vi.fn((handler) => {
+ this.dataHandler = handler;
+ return { dispose: vi.fn() };
+ });
+ instances.push(this);
+ }
+ }
+
+ return { TerminalClass, instances };
+}
+
+describe('createTerminalView', () => {
+ it('waits for the terminal font before opening the terminal view', async () => {
+ const { TerminalClass, instances } = createTerminalClass();
+ let resolveFont;
+ const waitForFont = vi.fn(() => new Promise((resolve) => {
+ resolveFont = resolve;
+ }));
+ const container = {};
+
+ createTerminalView(container, {}, { TerminalClass, waitForFont });
+
+ expect(waitForFont).toHaveBeenCalledTimes(1);
+ expect(instances).toHaveLength(0);
+
+ resolveFont();
+ await Promise.resolve();
+
+ expect(instances).toHaveLength(1);
+ expect(instances[0].options.scrollback).toBe(1000);
+ expect(instances[0].open).toHaveBeenCalledWith(container);
+ expect(instances[0].focus).toHaveBeenCalledTimes(1);
+ expect(instances[0].onData).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates theme without rewriting unchanged terminal content', async () => {
+ const { TerminalClass, instances } = createTerminalClass();
+ const preview = createTerminalView({}, {}, {
+ TerminalClass,
+ waitForFont: () => Promise.resolve(),
+ });
+
+ preview.render('same-sequence', { background: '#000000' });
+ await Promise.resolve();
+
+ const terminal = instances[0];
+ expect(terminal.reset).toHaveBeenCalledTimes(1);
+ expect(terminal.write).toHaveBeenCalledTimes(1);
+ expect(terminal.write).toHaveBeenCalledWith('same-sequence');
+ expect(terminal.options.theme).toEqual({ background: '#000000' });
+
+ preview.render('same-sequence', { background: '#111111' });
+
+ expect(terminal.reset).toHaveBeenCalledTimes(1);
+ expect(terminal.write).toHaveBeenCalledTimes(1);
+ expect(terminal.options.theme).toEqual({ background: '#111111' });
+ });
+
+ it('rewrites terminal content when the sequence changes', async () => {
+ const { TerminalClass, instances } = createTerminalClass();
+ const preview = createTerminalView({}, {}, {
+ TerminalClass,
+ waitForFont: () => Promise.resolve(),
+ });
+
+ preview.render('first-sequence', {});
+ await Promise.resolve();
+ preview.render('second-sequence', {});
+
+ expect(instances[0].reset).toHaveBeenCalledTimes(2);
+ expect(instances[0].write).toHaveBeenCalledTimes(2);
+ expect(instances[0].write).toHaveBeenLastCalledWith('second-sequence');
+ });
+
+ it('echoes typed commands and writes command output on enter', async () => {
+ const { TerminalClass, instances } = createTerminalClass();
+ const preview = createTerminalView({}, {
+ prompt: 'ciembor@browser ~> ',
+ runCommand: (command) => command === 'diff' ? 'diff-output' : '',
+ }, {
+ TerminalClass,
+ waitForFont: () => Promise.resolve(),
+ });
+
+ preview.render('initial-sequence', {});
+ await Promise.resolve();
+
+ const terminal = instances[0];
+ terminal.write.mockClear();
+
+ terminal.dataHandler('diff');
+ terminal.dataHandler('\r');
+
+ expect(terminal.write.mock.calls.map(([value]) => value)).toEqual([
+ 'diff',
+ '\r\n',
+ 'diff-output',
+ '\r\n',
+ '\r\n',
+ 'ciembor@browser ~> ',
+ ]);
+ });
+
+ it('supports backspace while editing the current command', async () => {
+ const { TerminalClass, instances } = createTerminalClass();
+ const runCommand = vi.fn(() => 'ok');
+ const preview = createTerminalView({}, {
+ prompt: '> ',
+ runCommand,
+ }, {
+ TerminalClass,
+ waitForFont: () => Promise.resolve(),
+ });
+
+ preview.render('initial-sequence', {});
+ await Promise.resolve();
+
+ const terminal = instances[0];
+ terminal.write.mockClear();
+
+ terminal.dataHandler('colorx');
+ terminal.dataHandler('\x7F');
+ terminal.dataHandler('s');
+ terminal.dataHandler('\r');
+
+ expect(runCommand).toHaveBeenCalledWith('colors');
+ expect(terminal.write.mock.calls.map(([value]) => value)).toContain('\b \b');
+ });
+
+ it('clears the terminal and keeps later theme updates from restoring the boot transcript', async () => {
+ const { TerminalClass, instances } = createTerminalClass();
+ const preview = createTerminalView({}, {
+ prompt: '> ',
+ runCommand: (command) => command === 'clear' ? { type: 'clear' } : '',
+ }, {
+ TerminalClass,
+ waitForFont: () => Promise.resolve(),
+ });
+
+ preview.render('boot-sequence', { background: '#000000' });
+ await Promise.resolve();
+
+ const terminal = instances[0];
+ terminal.write.mockClear();
+ terminal.reset.mockClear();
+
+ terminal.dataHandler('clear');
+ terminal.dataHandler('\r');
+
+ expect(terminal.write.mock.calls.map(([value]) => value)).toEqual([
+ 'clear',
+ '\r\n',
+ '> ',
+ ]);
+ expect(terminal.reset).toHaveBeenCalledTimes(1);
+
+ preview.render('boot-sequence', { background: '#111111' });
+
+ expect(terminal.write.mock.calls.map(([value]) => value)).toEqual([
+ 'clear',
+ '\r\n',
+ '> ',
+ ]);
+ expect(terminal.reset).toHaveBeenCalledTimes(1);
+ expect(terminal.options.theme).toEqual({ background: '#111111' });
+ });
+
+ it('refreshes the active dynamic command without restoring the boot transcript', async () => {
+ const { TerminalClass, instances } = createTerminalClass();
+ const runCommand = vi.fn((command) => ({
+ type: 'dynamic',
+ content: `report-${command}-${runCommand.mock.calls.length}`,
+ }));
+ const preview = createTerminalView({}, {
+ prompt: '> ',
+ runCommand,
+ }, {
+ TerminalClass,
+ waitForFont: () => Promise.resolve(),
+ });
+
+ preview.render('boot-sequence', { background: '#000000' });
+ await Promise.resolve();
+
+ const terminal = instances[0];
+ terminal.write.mockClear();
+ terminal.reset.mockClear();
+
+ terminal.dataHandler('usability');
+ terminal.dataHandler('\r');
+
+ expect(terminal.reset).not.toHaveBeenCalled();
+ expect(terminal.write.mock.calls.map(([value]) => value)).toEqual([
+ 'usability',
+ '\r\n',
+ 'report-usability-1',
+ '\r\n',
+ '\r\n',
+ '> ',
+ ]);
+
+ terminal.write.mockClear();
+ terminal.reset.mockClear();
+
+ preview.render('boot-sequence', { background: '#111111' });
+ preview.refreshDynamicCommand();
+
+ expect(terminal.reset).not.toHaveBeenCalled();
+ expect(terminal.write.mock.calls.map(([value]) => value)).toEqual([
+ '\x1b[2A\r\x1b[J',
+ 'report-usability-2',
+ '\r\n',
+ '\r\n',
+ '> ',
+ ]);
+ expect(terminal.options.theme).toEqual({ background: '#111111' });
+ });
+
+ it('stops refreshing the active dynamic command after the user starts typing', async () => {
+ const { TerminalClass, instances } = createTerminalClass();
+ const runCommand = vi.fn(() => ({ type: 'dynamic', content: 'report' }));
+ const preview = createTerminalView({}, {
+ prompt: '> ',
+ runCommand,
+ }, {
+ TerminalClass,
+ waitForFont: () => Promise.resolve(),
+ });
+
+ preview.render('boot-sequence', {});
+ await Promise.resolve();
+
+ const terminal = instances[0];
+ terminal.dataHandler('usability');
+ terminal.dataHandler('\r');
+ terminal.write.mockClear();
+ terminal.reset.mockClear();
+
+ terminal.dataHandler('l');
+ preview.refreshDynamicCommand();
+
+ expect(terminal.write.mock.calls.map(([value]) => value)).toEqual(['l']);
+ expect(terminal.reset).not.toHaveBeenCalled();
+ });
+});
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..da0af12
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,48 @@
+import { defineConfig } from 'vite';
+import vue from '@vitejs/plugin-vue';
+import { resolve } from 'path';
+import { readFileSync } from 'fs';
+import { configDefaults } from 'vitest/config';
+
+const jqueryUiVersion = JSON.parse(
+ readFileSync('./node_modules/jquery-ui/package.json', 'utf8')
+).version;
+
+export default defineConfig(({ command }) => ({
+ base: command === 'build' ? '/4bit/' : '/',
+ plugins: [vue()],
+ build: {
+ rollupOptions: {
+ input: {
+ main: resolve(__dirname, 'index.html'),
+ about: resolve(__dirname, 'about/index.html'),
+ },
+ },
+ },
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'src'),
+ },
+ },
+ optimizeDeps: {
+ include: ['jquery', 'jquery-ui'],
+ },
+ test: {
+ exclude: [
+ ...configDefaults.exclude,
+ 'tests/e2e/**',
+ ],
+ coverage: {
+ provider: 'v8',
+ all: true,
+ reportsDirectory: './coverage',
+ reporter: ['text-summary', 'json-summary', 'html', 'lcov'],
+ include: ['src/**/*.js'],
+ exclude: [
+ 'src/presentation/editor-page/main.js',
+ 'src/presentation/about-page/main.js',
+ 'src/infrastructure/vendor/**',
+ ],
+ },
+ },
+}));