BareGit

Add button maker. Support basic editing.

Author: MetroWind <chris.corsair@gmail.com>
Date: Sat Sep 27 13:05:27 2025 -0700
Commit: 81b539d6a63dd4921686fb50f05daf1b1d725e3b

Changes

diff --git a/button-maker/index.html b/button-maker/index.html
new file mode 100644
index 0000000..6e5aba0
--- /dev/null
+++ b/button-maker/index.html
@@ -0,0 +1,28 @@
+<!doctype html>
+<html lang="en-US">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport"
+          content="width=device-width, initial-scale=1" />
+    <link rel="stylesheet" type="text/css" href="styles.css" media="screen" />
+    <link rel="preconnect" href="https://fonts.googleapis.com">
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+    <link href="https://fonts.googleapis.com/css2?family=Kalam:wght@300;400;700&display=swap" rel="stylesheet">
+
+    <link rel="stylesheet" href="styles.css" />
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/preact/10.27.1/preact.min.js"
+            crossorigin="anonymous"
+            referrerpolicy="no-referrer"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/preact/10.27.1/hooks.umd.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
+    <script src="scripts/lib.js"></script>
+    <script type="module" defer src="scripts/main.js"></script>
+    <title>Button maker</title>
+  </head>
+  <body>
+    <div class="Window" id="MainWindow">
+      <h1 class="Titlebar">Button Maker</h1>
+      <div id="App">
+      </div>
+    </div>
+  </body>
+</html>
diff --git a/button-maker/scripts/lib.js b/button-maker/scripts/lib.js
new file mode 100644
index 0000000..372d9d7
--- /dev/null
+++ b/button-maker/scripts/lib.js
@@ -0,0 +1,79 @@
+// In Firefox (on Mac only?), for <input type="color">, setting value
+// to abbreviated hex form (e.g. #123) doesn’t work. So all colors
+// should be specified in full form.
+const DEFAULT_NETSCAPE_PARAMS = {
+    color_bg: "#c0c0c0",
+    logo_url: "https://upload.wikimedia.org/wikipedia/commons/0/08/Netscape_icon.svg",
+    text_top: {
+        content: "Netscape",
+        pos: [31, 11],
+        font_size: 10.5,
+        color: "#000000",
+    },
+    text_bottom: {
+        content: "Now!",
+        pos: [30, 28],
+        font_size: 17.5,
+        color: "#ff0000",
+    },
+    banner: {
+        style: "corner_bottom_right", // corner_bottom_right or right
+        color: "#008080",
+        text: {
+            content: "5.0",
+            pos: [65, 46],
+            font_size: 8,
+            color: "#ffffff",
+        },
+    },
+};
+
+const DEFAULT_APP_STATE = {
+    button_type: "netscape",
+    button_params: DEFAULT_NETSCAPE_PARAMS,
+}
+
+const SVG_ATTRIBUTES = {viewBox: "0 0 88 31", width: 88, height: 31};
+
+// Return three integers as an array.
+function parseHexColor(color_str)
+{
+    console.debug("Parsing", color_str);
+    m = color_str.match(/^#([0-9a-f]{3})$/i);
+    if(m)
+    {
+        // in three-character format, each value is multiplied by 0x11 to give an
+        // even scale from 0x00 to 0xff
+        return [
+            parseInt(m[1].charAt(0),16)*0x11,
+            parseInt(m[1].charAt(1),16)*0x11,
+            parseInt(m[1].charAt(2),16)*0x11
+        ];
+    }
+
+    m = color_str.match(/^#([0-9a-f]{6})$/i);
+    if(m)
+    {
+        return [
+            parseInt(m[1].substr(0,2),16),
+            parseInt(m[1].substr(2,2),16),
+            parseInt(m[1].substr(4,2),16)
+        ];
+    }
+    return null;
+}
+
+function lighten(rgb, delta)
+{
+    return [rgb[0] + delta, rgb[1] + delta, rgb[2] + delta];
+}
+
+function darken(rgb, delta)
+{
+    return [rgb[0] - delta, rgb[1] - delta, rgb[2] - delta];
+}
+
+function color2Str(color)
+{
+    return `rgb(${color[0]} ${color[1]} ${color[2]})`;
+}
diff --git a/button-maker/scripts/main.js b/button-maker/scripts/main.js
new file mode 100644
index 0000000..7232064
--- /dev/null
+++ b/button-maker/scripts/main.js
@@ -0,0 +1,279 @@
+const h = preact.h;
+
+function ButtonViewNetscape({app_state})
+{
+    const params = app_state.button_params;
+    console.debug("Rendering netscape params", params);
+    let color_bg = parseHexColor(params.color_bg);
+    let color_hilight_1 = color2Str(lighten(color_bg, 63));
+    let color_hilight_2 = color2Str(lighten(color_bg, 31));
+    let color_shadow_1 = color2Str(darken(color_bg, 0xc0 - 0xa));
+    let color_shadow_2 = color2Str(darken(color_bg, 0xc0 - 0x54));
+
+    let ribbon = null;
+    let ribbon_text = null;
+    if(app_state.button_params.banner.style == "corner_bottom_right")
+    {
+        ribbon = h("polygon", {points: "86,8 86,24 81,29 65,29",
+                               fill: params.banner.color});
+        ribbon_text = h("text", {
+            x: params.banner.text.pos[0], y: params.banner.text.pos[1],
+            "font-family": "sans-serif",
+            "dominant-baseline": "middle", "text-anchor": "middle",
+            "transform-origin": "center", "transform":"rotate(-45)",
+            "font-weight": "bold", "font-size": params.banner.text.font_size,
+            fill: params.banner.text.color, "stroke-width": 0},
+                        params.banner.text.content);
+    }
+    else if(app_state.button_params.banner.style == "right")
+    {
+        ribbon = h("rect", {x: 78, y: 2, width: 8, height: 27,
+                            fill: app_state.button_params.banner.color});
+        ribbon_text = h("text", {
+            x: params.banner.text.pos[0], y: params.banner.text.pos[1],
+            "font-family": "sans-serif", "dominant-baseline": "middle",
+            "text-anchor": "middle", "transform-origin": "center",
+            transform: "rotate(-90)", "font-weight": "bold",
+            "font-size": params.banner.text.font_size,
+            fill: params.banner.text.color, "stroke-width": 0},
+                        params.banner.text.content);
+    }
+
+    return h("svg", SVG_ATTRIBUTES,
+             h("g", {"stroke-width": 0},
+               h("polygon", {points: "0,0 88,0 87,1 1,1",
+                             fill: color_hilight_1}),
+               h("polygon", {points: "1,1 87,1 86,2 2,2",
+                             fill: color_hilight_2}),
+               h("polygon", {points: "88,0 88,31 87,30 87,1",
+                             fill: color_shadow_1}),
+               h("polygon", {points: "87,1 87,30 86,29 86,2",
+                             fill: color_shadow_2}),
+               h("polygon", {points: "88,31 0,31 1,30 87,30",
+                             fill: color_shadow_1}),
+               h("polygon", {points: "87,30 1,30 2,29,86,29",
+                             fill: color_shadow_2}),
+               h("polygon", {points: "0,0 1,1 1,30 0,31",
+                             fill: color_hilight_1}),
+               h("polygon", {points: "1,1 2,2 2,29 1,30",
+                             fill: color_hilight_2}),
+               h("rect", {x: 2, y: 2, width: 84, height: 27,
+                          fill: params.color_bg}),
+               ribbon),
+             h("image", {href: params.logo_url, x: 3, y: 3, width: 25,
+                         height: 25}),
+             h("text", {x: params.text_bottom.pos[0],
+                        y: params.text_bottom.pos[1], "font-family": "Kalam",
+                        "font-size": params.text_bottom.font_size,
+                        fill: params.text_bottom.color, "stroke-width": 0},
+               "Now!"),
+             h("text", {x: params.text_top.pos[0], y: params.text_top.pos[1],
+                        "font-family": "sans-serif",
+                        "font-size": params.text_top.font_size,
+                        fill: params.text_top.color, "stroke-width": 0},
+               params.text_top.content),
+             ribbon_text);
+}
+
+function TextControlView({params, on_change})
+{
+    console.debug("Rendering text params", params);
+    function onContentChange(e)
+    {
+        let new_params = structuredClone(params);
+        new_params.content = e.target.value;
+        on_change(new_params);
+    }
+
+    function onPosXChange(e)
+    {
+        let new_params = structuredClone(params);
+        new_params.pos[0] = parseFloat(e.target.value);
+        on_change(new_params);
+    }
+
+    function onPosYChange(e)
+    {
+        let new_params = structuredClone(params);
+        new_params.pos[1] = parseFloat(e.target.value);
+        on_change(new_params);
+    }
+
+    function onFontSizeChange(e)
+    {
+        let new_params = structuredClone(params);
+        new_params.font_size = parseFloat(e.target.value);
+        on_change(new_params);
+    }
+
+    function onColorChange(e)
+    {
+        let new_params = structuredClone(params);
+        new_params.color = e.target.value;
+        on_change(new_params);
+    }
+
+    return h(preact.Fragment, {},
+             h("div", {"class": "ButtonRowLeft"},
+               h("label", {}, "Content"),
+               h("input", {type: "text", value: params.content,
+                           onchange: onContentChange}),
+               h("label", {}, "Color"),
+               h("input", {type: "color",
+                           value: params.color,
+                           onchange: onColorChange})),
+             h("div", {"class": "ButtonRowLeft"},
+               h("label", {}, "x"),
+               h("input", {type: "number", value: params.pos[0],
+                           onchange: onPosXChange}),
+               h("label", {}, "y"),
+               h("input", {type: "number", value: params.pos[1],
+                           onchange: onPosYChange}),
+               h("label", {}, "Font size"),
+               h("input", {type: "number", value: params.font_size,
+                           min: 0, max: 88, step: 0.5,
+                           onchange: onFontSizeChange})));
+}
+
+function EditorViewNetscape({app_state, on_state_change})
+{
+    const params = app_state.button_params;
+    function onLogoURLChange(e)
+    {
+        let new_state = structuredClone(app_state);
+        new_state.button_params.logo_url = e.target.value;
+        on_state_change(new_state);
+    }
+
+    function onBGColorChange(e)
+    {
+        let new_state = structuredClone(app_state);
+        new_state.button_params.color_bg = e.target.value;
+        on_state_change(new_state);
+    }
+
+    function onTextTopChange(new_params)
+    {
+        let new_state = structuredClone(app_state);
+        new_state.button_params.text_top = new_params;
+        on_state_change(new_state);
+    }
+
+    function onTextBottomChange(new_params)
+    {
+        let new_state = structuredClone(app_state);
+        new_state.button_params.text_bottom = new_params;
+        on_state_change(new_state);
+    }
+
+    function onRibbonStyleChange(e)
+    {
+        if(app_state.button_params.banner.style == e.target.value)
+        {
+            return;
+        }
+        let new_state = structuredClone(app_state);
+        let banner = new_state.button_params.banner;
+        banner.style = e.target.value;
+
+        // With the style change, the text also needs adjustments. We
+        // will just set it to the style’s default.
+        if(banner.style == "corner_bottom_right")
+        {
+            banner.text.pos = [65, 46];
+        }
+        else if(banner.style == "right")
+        {
+            banner.text.pos = [44, 54];
+        }
+
+        on_state_change(new_state);
+    }
+
+    function onRibbonColorChange(e)
+    {
+        let new_state = structuredClone(app_state);
+        new_state.button_params.banner.color = e.target.value;
+        on_state_change(new_state);
+    }
+
+    function onRibbonTextChange(new_text)
+    {
+        let new_state = structuredClone(app_state);
+        new_state.button_params.banner.text = new_text;
+        on_state_change(new_state);
+    }
+
+    return h("div", {},
+             h("div", {"class": "ButtonRowLeft"},
+               h("label", {}, "Background color"),
+               h("input", {type: "color",
+                           value: params.color_bg,
+                           onchange: onBGColorChange})),
+             h("div", {"class": "ButtonRowLeft"},
+               h("label", {}, "Logo URL"),
+               h("input", {type: "url", value: params.logo_url,
+                           onchange: onLogoURLChange})),
+             h("div", {"class": "LabeledPanel"},
+               h("h2", {}, "Main text"),
+               h(TextControlView, {params: params.text_top,
+                                   on_change: onTextTopChange})),
+             h("div", {"class": "LabeledPanel"},
+               h("h2", {}, "Flavor text"),
+               h(TextControlView, {params: params.text_bottom,
+                                   on_change: onTextBottomChange})),
+             h("div", {"class": "LabeledPanel"},
+               h("h2", {}, "Ribbon"),
+               h("div", {"class": "ButtonRowLeft"},
+                 h("label", {}, "Style"),
+                 h("select", {value: params.banner.style,
+                              onchange: onRibbonStyleChange},
+                   h("option", {value: "corner_bottom_right"}, "Bottom right"),
+                   h("option", {value: "right"}, "Vertical right")),
+                 h("label", {}, "Background color"),
+                 h("input", {type: "color",
+                             value: params.banner.color,
+                             onchange: onRibbonColorChange})),
+               h("div", {"class": "LabeledPanel"},
+                 h("h2", {}, "Text"),
+                 h(TextControlView, {params: params.banner.text,
+                                     on_change: onRibbonTextChange})),
+
+              ),
+
+
+            );
+}
+
+function EditorView({app_state})
+{
+    const [state, setState] = preactHooks.useState(app_state);
+    let preview_view = null;
+    let editor_view = null;
+    if(app_state.button_type == "netscape")
+    {
+        preview_view = ButtonViewNetscape;
+        editor_view = EditorViewNetscape;
+    }
+
+    function onStateChange(new_state)
+    {
+        console.debug("New state is", new_state);
+        setState(new_state);
+    }
+
+    return h("div", {},
+             h(preview_view, {app_state: state}),
+             h("select", {},
+               h("option", {value: "netscape"}, "Netscape"),
+              ),
+             h(editor_view, {app_state: state, on_state_change: onStateChange}));
+}
+
+function App()
+{
+    return h("div", {},
+             h(EditorView, {app_state: DEFAULT_APP_STATE}));
+}
+
+preact.render(h(App, {}), document.getElementById("App"));
diff --git a/button-maker/scripts/tiles.js b/button-maker/scripts/tiles.js
new file mode 100644
index 0000000..f35887a
--- /dev/null
+++ b/button-maker/scripts/tiles.js
@@ -0,0 +1,94 @@
+const TILES = [
+    null,
+    {
+        id: "blank",
+        name: "black",
+        inner_svg: null,
+    }, {
+        id: "teleporter",
+        name: "teleporter",
+        inner_svg: h("circle", {
+            "cx": CELL_SIZE * 0.5, "cy": CELL_SIZE * 0.5,
+            "r": (CELL_SIZE - 10) * 0.5, "stroke": "black",
+            "fill": "transparent", "stroke-width": 6,
+        }),
+    }, {
+        id: "fleet_command",
+        name: "fleet command",
+        inner_svg: h("polyline", {
+            "points": `3, ${CELL_SIZE} \
+3, ${CELL_SIZE - 15} \
+13, ${CELL_SIZE - 25} \
+13, ${CELL_SIZE - 5} \
+${CELL_SIZE - 13}, ${CELL_SIZE - 5} \
+${CELL_SIZE - 13}, ${CELL_SIZE - 25} \
+${CELL_SIZE - 3}, ${CELL_SIZE - 15} \
+${CELL_SIZE - 3}, ${CELL_SIZE}`,
+            "fill": "black", "stroke-width": 0,
+        }),
+    }, {
+        id: "scanner",
+        name: "scanner",
+        inner_svg:
+        h(preact.Fragment, {},
+          h("circle", {"cx": CELL_SIZE * 0.5, "cy": CELL_SIZE * 0.5,
+                       "r": CELL_SIZE * 0.5 - 2, "fill": "transparent",
+                       "stroke": "black", "stroke-width": 2}),
+          h("circle", {"cx": CELL_SIZE * 0.5, "cy": CELL_SIZE * 0.5,
+                       "r": CELL_SIZE * 0.25, "fill": "transparent",
+                       "stroke": "black", "stroke-width": 2}),
+          h("line", {"x1": 2, "y1": CELL_SIZE * 0.5,
+                     "x2": CELL_SIZE - 2, "y2": CELL_SIZE * 0.5,
+                     "stroke": "black", "stroke-width": 2}),
+          h("line", {"y1": 2, "x1": CELL_SIZE * 0.5,
+                     "y2": CELL_SIZE - 2, "x2": CELL_SIZE * 0.5,
+                     "stroke": "black", "stroke-width": 2}),
+          h("line", {"x1": CELL_SIZE * 0.5, "y1": CELL_SIZE * 0.5,
+                     "x2": CELL_SIZE * 0.5 + (CELL_SIZE * 0.5 - 2) * Math.cos(Math.PI / 6),
+                     "y2": CELL_SIZE * 0.5 - (CELL_SIZE * 0.5 - 2) * Math.sin(Math.PI / 6),
+                     "stroke": "black", "stroke-width": 2}),
+          h("circle", {"cx": 22, "cy": 6, "r": 2, "fill": "black",
+                       "stroke-width": 0}),
+          h("circle", {"cx": 10, "cy": 22, "r": 4, "fill": "black",
+                       "stroke-width": 0}),
+
+         ),
+    }, {
+        id: "extractor",
+        name: "stellar extractor",
+        inner_svg:
+        h(preact.Fragment, {},
+          h("circle", {"cx": CELL_SIZE * 0.5, "cy": 21,
+                       "r": 10, "fill": "black", "stroke-width": 0}),
+          h("polygon", {"points": `${CELL_SIZE * 0.5 - 4}, 20 \
+${CELL_SIZE * 0.5 - 4}, 10 \
+${CELL_SIZE * 0.5 - 8}, 10 \
+${CELL_SIZE * 0.5}, 3 \
+${CELL_SIZE * 0.5 + 8}, 10 \
+${CELL_SIZE * 0.5 + 4}, 10 \
+${CELL_SIZE * 0.5 + 4}, 20`,
+                        "stroke": "black", "stroke-width": 2,
+                        "fill": "white"}),
+         ),
+    }, {
+        id: "storage",
+        name: "storage",
+        inner_svg:
+        h(preact.Fragment, {},
+          h("rect", {"x": 4, "y": 4, "width": CELL_SIZE - 8,
+                          "height": CELL_SIZE - 8, "rx": 2, "ry": 2,
+                     "fill": "black", "stroke-width": 0}),
+          h("line", {"x1": 4, "y1": 14, "x2": CELL_SIZE - 4, "y2": 14,
+                     "stroke": "white", "stroke-width": 2}),
+          h("rect", {"x": CELL_SIZE * 0.5 - 4, "y": 10, "width": 8, "height": 8,
+                     "fill": "white", "stroke-width": 0}),
+         ),
+    }, {
+        id: "plant",
+        name: "double cultivation chamber",
+        inner_svg: h("path", {"d": "m3.02 17.7a13 13 0 0013 13 13 13 0 00-13-13m13 13a13 13 0 0013-13 13 13 0 00-13 13m8.66-27.4v7.21a8.66 8.66 0 01-8.66 8.66 8.66 8.66 0 01-8.66-8.66v-7.21c1.07 0 2.12.173 3.12.563.793.332 1.5.821 2.09 1.44l3.43-3.43 3.43 3.43c.591-.622 1.3-1.11 2.09-1.44.996-.388 2.05-.563 3.12-.563z",
+                              "fill": "black", "stroke-width": 0}),
+    }
+];
+
+const TILE_MAP = Object.fromEntries(TILES.slice(1).map(t => [t.id, t]));
diff --git a/button-maker/styles.css b/button-maker/styles.css
new file mode 100644
index 0000000..bee2d4b
--- /dev/null
+++ b/button-maker/styles.css
@@ -0,0 +1,326 @@
+/* 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: unset;
+	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;
+}
+
+/* ========== Actual styles ======================================> */
+
+:root
+{
+    --color-bg: #c0c0c0;
+    --color-fg: black;
+    --color-border-dark-1: #808080;
+    --color-border-dark-2: black;
+    --color-border-light-1: #dfdfdf;
+    --color-border-light-2: white;
+    --color-titlebar-1: navy;
+    --color-titlebar-2: #1084d0;
+}
+
+*
+{
+    box-sizing: border-box;
+}
+
+html
+{
+    background: #008080;
+    color: var(--color-fg);
+    font-family: sans-serif;
+    font-size: 12px;
+    padding: 12px;
+}
+
+.Window
+{
+    box-shadow: inset -1px -1px var(--color-border-dark-2),
+                inset 1px 1px var(--color-border-light-1),
+                inset -2px -2px var(--color-border-dark-1),
+                inset 2px 2px var(--color-border-light-2);
+    background: var(--color-bg);
+    padding: 10px;
+
+    .Titlebar
+    {
+        background: linear-gradient(90deg, var(--color-titlebar-1), var(--color-titlebar-2));
+        margin: -6px -6px 10px -6px;
+        color: white;
+        padding: 3px;
+    }
+}
+
+.Dialog
+{
+    max-width: 910px;
+    margin: 0px auto;
+}
+
+.LabeledPanel
+{
+    border-top: solid var(--color-border-light-1) 1px;
+    border-right: solid var(--color-border-dark-1) 1px;
+    border-bottom: solid var(--color-border-dark-1) 1px;
+    border-left: solid var(--color-border-light-1) 1px;
+
+    box-shadow: 1px 1px var(--color-border-light-1),
+                -1px -1px var(--color-border-dark-1);
+    padding: 1ex;
+    padding-top: 0px;
+    margin: 2ex 0px;
+}
+
+.LabeledPanel > h3, .LabeledPanel > h2
+{
+    font-weight: normal;
+    display: inline-block;
+    position: relative;
+    top: calc(-0.5em);
+    left: 1ex;
+    background-color: var(--color-bg);
+    padding: 0px 1ex 0px 1ex;
+    margin: 0px;
+}
+
+input[type="number"]
+{
+    appearance: none;
+    border: none;
+    outline: none;
+    box-shadow: inset -1px -1px var(--color-border-light-2),
+                inset 1px 1px var(--color-border-dark-1),
+                inset -2px -2px var(--color-border-light-1),
+                inset 2px 2px var(--color-border-dark-2);
+    padding: 4px 0px 4px 6px;
+    font-family: monospace;
+    font-size: 0.9rem;
+}
+
+input[type="text"], input[type="url"], input[type="email"], textarea
+{
+    appearance: none;
+    border: none;
+    outline: none;
+    box-shadow: inset -1px -1px var(--color-border-light-2),
+                inset 1px 1px var(--color-border-dark-1),
+                inset -2px -2px var(--color-border-light-1),
+                inset 2px 2px var(--color-border-dark-2);
+    width: 100%;
+    padding: 4px 6px;
+    font-family: monospace;
+    font-size: 0.9rem;
+}
+
+a.Button, a.FloatButton
+{
+    color: black;
+    text-decoration: none;
+}
+
+button, input[type="submit"], input[type="color"]
+{
+    appearance: none;
+    border: none;
+    border-radius: 0px;
+    outline: none;
+    box-shadow: inset -1px -1px var(--color-border-dark-2),
+                inset 1px 1px var(--color-border-light-1),
+                inset -2px -2px var(--color-border-dark-1),
+                inset 2px 2px var(--color-border-light-2);
+    background: var(--color-bg);
+    text-align: center;
+}
+
+button, input[type="submit"]
+{
+    min-height: 23px;
+    min-width: 75px;
+    padding: 0 12px;
+}
+
+input[type="color"]
+{
+    padding: 4px;
+    width: 3em;
+    height: 2em;
+}
+
+.IconButton
+{
+    padding: 4px;
+    min-width: unset;
+    min-height: unset;
+}
+
+.FloatButton
+{
+    padding: 4px;
+    min-width: unset;
+    min-height: unset;
+    box-shadow: none;
+}
+
+.FloatButton:hover
+{
+    padding: 4px;
+    min-width: unset;
+    min-height: unset;
+    box-shadow: inset 1px 1px var(--color-border-light-1),
+                inset -1px -1px var(--color-border-dark-1);
+}
+
+button:active, .FloatButton:active
+{
+    box-shadow: inset -1px -1px var(--color-border-light-2),
+                inset 1px 1px var(--color-border-dark-1),
+                inset -2px -2px var(--color-border-light-1),
+                inset 2px 2px var(--color-border-dark-2);
+}
+
+hr
+{
+    border: none;
+    border-top: solid var(--color-border-dark-1) 1px;
+    border-bottom: solid var(--color-border-light-1) 1px;
+}
+
+.ButtonRow, .ButtonRowLeft
+{
+    display: flex;
+    gap: 10px;
+    margin: 10px 0;
+    align-items: baseline;
+}
+
+.ButtonRow
+{
+    justify-content: right;
+}
+
+.Toolbar
+{
+    display: flex;
+    border-bottom: solid var(--color-border-dark-1) 1px;
+    box-shadow: 0px 1px var(--color-border-light-1);
+    position: relative;
+    left: -10px;
+    top: -10px;
+    width: calc(100% + 2ex);
+    padding: 2px 8px;
+}
+
+table.InputFields
+{
+    border-collapse: collapse;
+    width: 100%;
+    table-layout: auto;
+
+    td
+    {
+        padding: 2px 2px;
+    }
+
+    td:nth-child(2)
+    {
+        width: 100%;
+    }
+
+}
+
+.StatusBar
+{
+    margin: 10px -6px -7px -6px;
+    display: flex;
+}
+
+.StatusCell
+{
+    box-shadow: inset -2px -2px var(--color-border-light-2),
+                inset 2px 2px var(--color-border-dark-1);
+    padding: 4px;
+    flex-grow: 1;
+}
+
+#Links
+{
+    box-shadow: inset -1px -1px var(--color-border-light-2),
+                inset 1px 1px var(--color-border-dark-1),
+                inset -2px -2px var(--color-border-light-1),
+                inset 2px 2px var(--color-border-dark-2);
+    padding: 2px;
+}
+
+table.TableView
+{
+    background-color: white;
+    border-collapse: collapse;
+    width: 100%;
+
+    thead th
+    {
+        box-shadow: inset -1px -1px var(--color-border-dark-2),
+                    inset 1px 1px var(--color-border-light-1),
+                    inset -2px -2px var(--color-border-dark-1),
+                    inset 2px 2px var(--color-border-light-2);
+        background-color: var(--color-bg);
+        padding: 2px 4px 4px 4px;
+        text-align: left;
+    }
+
+    td
+    {
+        padding: 2px;
+    }
+}
+
+/* ========== App-specific =======================================> */
+
+#MainWindow
+{
+    max-width: 600px;
+    margin-left: auto;
+    margin-right: auto;
+}