Created simple web app, added component selection & CSS download

This commit is contained in:
Filip Znachor 2023-10-09 16:30:25 +02:00
parent 43d4f32049
commit ae9738d084
10 changed files with 240 additions and 89 deletions

3
.gitignore vendored
View file

@ -1 +1,4 @@
node_modules
dist
.vercel
pnpm-lock.yaml

View file

@ -8,7 +8,7 @@
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="content">
<div class="content" id="app">
<header>
<h1>
@ -16,22 +16,61 @@
</h1>
<p>Simple framework with CSS-only UI components</p>
<div class="colorselector">
<input type="color" value="#865e3c" title="Main color">
<input type="color" value="#63452c" title="Text color">
<input type="color" value="#f9f7f4" title="Background color">
<div class="config" title="Settings">
<input type="color" v-model="data.preset.main" @change="applyPreset(data.preset);" title="Main color">
<input type="color" v-model="data.preset.text" @change="applyPreset(data.preset);" title="Text color">
<input type="color" v-model="data.preset.bg" @change="applyPreset(data.preset);" title="Background color">
<div class="config" title="Settings" @click="settings = !settings;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M13.875 22h-3.75q-.375 0-.65-.25t-.325-.625l-.3-2.325q-.325-.125-.613-.3t-.562-.375l-2.175.9q-.35.125-.7.025t-.55-.425L2.4 15.4q-.2-.325-.125-.7t.375-.6l1.875-1.425Q4.5 12.5 4.5 12.337v-.674q0-.163.025-.338L2.65 9.9q-.3-.225-.375-.6t.125-.7l1.85-3.225q.175-.35.537-.438t.713.038l2.175.9q.275-.2.575-.375t.6-.3l.3-2.325q.05-.375.325-.625t.65-.25h3.75q.375 0 .65.25t.325.625l.3 2.325q.325.125.613.3t.562.375l2.175-.9q.35-.125.7-.025t.55.425L21.6 8.6q.2.325.125.7t-.375.6l-1.875 1.425q.025.175.025.338v.674q0 .163-.05.338l1.875 1.425q.3.225.375.6t-.125.7l-1.85 3.2q-.2.325-.563.438t-.712-.013l-2.125-.9q-.275.2-.575.375t-.6.3l-.3 2.325q-.05.375-.325.625t-.65.25Zm-1.825-6.5q1.45 0 2.475-1.025T15.55 12q0-1.45-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12q0 1.45 1.012 2.475T12.05 15.5Z"/></svg>
</div>
</div>
</header>
<aside class="settings">
<aside class="settings" :class="{ open: settings }">
<svg xmlns="http://www.w3.org/2000/svg" class="close" viewBox="0 0 24 24"><path fill="currentColor" d="M6.4 19L5 17.6l5.6-5.6L5 6.4L6.4 5l5.6 5.6L17.6 5L19 6.4L13.4 12l5.6 5.6l-1.4 1.4l-5.6-5.6L6.4 19Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" @click="settings = false;" class="close" viewBox="0 0 24 24"><path fill="currentColor" d="M6.4 19L5 17.6l5.6-5.6L5 6.4L6.4 5l5.6 5.6L17.6 5L19 6.4L13.4 12l5.6 5.6l-1.4 1.4l-5.6-5.6L6.4 19Z"/></svg>
<b>Presets</b>
<div class="presets"></div>
<div class="presets">
<div v-for="p in data.presets" @click="data.preset = p; applyPreset(p);">
<div :style="`background: ${p.main};`"></div>
<div :style="`background: ${p.text};`"></div>
<div :style="`background: ${p.bg};`"></div>
</div>
</div>
<b>Component selection</b>
<form class="form" @submit.prevent="generateCSS">
<label class="cbox" v-for="p in data.parts">
<input type="checkbox" v-model="p.enabled">
<span>{{ p.name }}</span>
</label>
<div class="btn-row">
<button class="btn btn-primary" type="submit">
Generate CSS
</button>
</div>
</form>
<template v-if="data.results.length">
<b>CSS download</b>
<div class="btn-row">
<a v-for="r in data.results" class="btn btn-primary btn-download" :download="r.name" :href="'data:text/plain;charset=utf-8,' + encodeURIComponent(r.css)">
{{ r.name }}
<small>
{{ kbSize(r.size_gzip) }} (gzip) · {{ kbSize(r.size) }}
</small>
</a>
</div>
</template>
</aside>
@ -103,6 +142,6 @@
</div>
</div>
<script src="web/demo.js"></script>
<script type="module" src="web/app.ts"></script>
</body>
</html>

21
package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "synergy",
"version": "1.0.0",
"description": "",
"main": "index.js",
"keywords": [],
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"author": "",
"license": "ISC",
"dependencies": {
"petite-vue": "^0.4.1"
},
"devDependencies": {
"typescript": "^5.0.2",
"vite": "^4.4.5"
}
}

View file

@ -17,6 +17,7 @@
cursor: pointer;
outline: none;
transition: box-shadow .2s;
text-decoration: none;
}
.btn:hover {

10
tsconfig.json Normal file
View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}

5
vite.config.ts Normal file
View file

@ -0,0 +1,5 @@
import { defineConfig } from 'vite'
export default defineConfig({
publicDir: "src/"
});

49
web/app.ts Normal file
View file

@ -0,0 +1,49 @@
import { Exporter, Preset, Theme } from "./lib";
import { createApp, reactive } from "petite-vue";
export function applyPreset(p: Preset) {
let theme = new Theme(p);
theme.apply();
let html = document.querySelector<HTMLElement>("html");
if(html) html.style.background = p.siteBg ?? "";
return theme;
}
export let presets: Preset[] = [
// {main: "#2ebdf5", text: "#ffffff", bg: "#040813"},
// {main: "#f5b62e", text: "#ffffff", bg: "#040813"},
// {main: "#FF6565", text: "#ffffff", bg: "#0f0413"},
{main: "#9b8fe4", text: "#cfcef4", bg: "#090818", siteBg: "#100E22"},
{main: "#337e2c", text: "#031601", bg: "#f3f7f2"},
{main: "#1c71d8", text: "#030e1c", bg: "#ffffff"},
{main: "#9141ac", text: "#613583", bg: "#f6edf7"},
{main: "#a51d2d", text: "#3d3846", bg: "#f1e9e8"},
{main: "#865e3c", text: "#63452c", bg: "#f9f7f4", siteBg: "#ffffff"}
];
let pr = presets[Math.floor(Math.random() * presets.length)];
let data = reactive({
theme: applyPreset(pr),
preset: pr,
parts: Exporter.parts,
presets,
results: []
});
applyPreset(pr);
createApp({
applyPreset(p: Preset) {
data.results = [];
applyPreset(p);
},
async generateCSS() {
data.results = await Exporter.get(data.theme);
},
kbSize(value: number) {
return `${Math.round(value/1024*100)/100} kB`;
},
data,
settings: false
}).mount("#app");

1
web/declaration.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module '*.css';

View file

@ -9,7 +9,8 @@ html, body {color: var(--synergy-text-color); font-family: Cantarell, ui-sans-se
@media screen and (max-width: 700px) {
.grid {grid-template-columns: 1fr; max-width: 500px;}
}
.form {display: flex; flex-direction: column; gap: 20px;}
.form {display: flex; flex-direction: column; gap: 15px;}
.form > * {margin: 0;}
header {margin-bottom: 70px; text-align: center;}
header > * {margin: 30px 0;}
@ -22,11 +23,15 @@ header h1 .color {background-clip: text; -webkit-background-clip: text; backgrou
.colorselector div {display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 3px #0004, inset 0 0 0 2px var(--synergy-border);}
.colorselector svg {width: 30px;}
.settings {position: fixed; right: -600px; top: 0; width: 90%; max-width: 400px; height: 100%; box-shadow: 0 0 0 5px var(--synergy-border-active); border-radius: 40px 0 0 40px; padding: 40px; transition: all .3s; display: flex; flex-direction: column; gap: 15px; z-index: 100; background-color: var(--synergy-bg);}
.settings {position: fixed; right: -600px; top: 0; width: 90%; max-width: 400px; height: 100%; box-shadow: 0 0 0 5px var(--synergy-border-active); border-radius: 40px 0 0 40px; padding: 40px; transition: all .3s; display: flex; flex-direction: column; gap: 20px; z-index: 100; background-color: var(--synergy-bg); overflow-y: auto;}
.settings.open {right: 0;}
.settings b {margin-top: 20px;}
.presets {display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px;}
.presets > * {height: 50px; border-radius: var(--synergy-border-radius); box-shadow: 0 1px 3px #0004; display: flex; overflow: hidden; cursor: pointer;}
.presets > * > * {flex: 1;}
.settings .close {width: 64px; height: 64px; cursor: pointer; padding: 20px; position: absolute; right: 0; top: 0; z-index: 10;}
.settings .close {width: 64px; height: 64px; cursor: pointer; padding: 20px; position: absolute; right: 0; top: 0; z-index: 10;}
.btn.btn-download small {font-weight: 400; font-size: 12px; display: block;}

View file

@ -1,78 +1,94 @@
import { reactive } from "petite-vue";
var style = document.createElement("style");
addEventListener("load", () => {
let cs = document.querySelectorAll(".colorselector > *");
cs.forEach(c => c.addEventListener("change", updateColors));
document.querySelector("head").appendChild(style);
updateColors();
showPresets();
const p = presets[Math.floor(Math.random() * presets.length)];
applyPreset(p);
document.querySelector(".colorselector .config").addEventListener("click", toggleSettings);
document.querySelector(".settings .close").addEventListener("click", toggleSettings);
document.querySelector("head")!.appendChild(style);
});
function updateColors() {
let cs = document.querySelectorAll(".colorselector > *");
let p = {main: cs[0].value, text: cs[1].value, bg: cs[2].value};
console.log(p);
let t = new Theme(p);
t.apply();
}
export namespace Exporter {
function toggleSettings() {
document.querySelector(".settings").classList.toggle("open");
}
export let parts = reactive([
{name: "Buttons", file: "button", enabled: true},
{name: "Fields", file: "input", enabled: true},
{name: "Toggles", file: "toggle", enabled: true},
{name: "Checkboxes and radios", file: "checkbox", enabled: true},
]);
let presets = [
{main: "#2ebdf5", text: "#ffffff", bg: "#040813"},
{main: "#f5b62e", text: "#ffffff", bg: "#040813"},
{main: "#FF6565", text: "#ffffff", bg: "#0f0413"},
{main: "#9b8fe4", text: "#cfcef4", bg: "#090818", siteBg: "#100E22"},
{main: "#337e2c", text: "#031601", bg: "#f3f7f2"},
{main: "#1c71d8", text: "#030e1c", bg: "#ffffff"},
{main: "#9141ac", text: "#613583", bg: "#f6edf7"},
{main: "#a51d2d", text: "#3d3846", bg: "#f1e9e8"},
{main: "#865e3c", text: "#63452c", bg: "#f9f7f4", siteBg: "#ffffff"}
];
interface Result {
name: string,
css: string,
size: number,
size_gzip: number
}
function showPresets() {
let presetsEl = document.querySelector(".presets");
presets.forEach(p => {
let el = document.createElement("div");
el.innerHTML = `
<div style="background-color: ${p.main};"></div>
<div style="background-color: ${p.text};"></div>
<div style="background-color: ${p.bg};"></div>`;
el.addEventListener("click", () => {
applyPreset(p);
export async function get(theme: Theme) {
let cssParts = [theme.generate()];
for(let p of parts) {
if(p.enabled) {
let value = await (await fetch(`./${p.file}.css`)).text();
value = `/* ${p.name} */\n\n${value}`;
cssParts.push(value);
}
}
let css = cssParts.join("\n\n/* ------------------- */\n\n");
let results: Result[] = [];
await addResult(results, "synergy.min.css", minify(css));
await addResult(results, "synergy.css", css);
return results;
}
async function addResult(results: Result[], name: string, css: string) {
results.push({
name,
css,
size: getSize(css),
size_gzip: await getCompressedSize(css)
});
presetsEl.appendChild(el);
});
}
async function getCompressedSize(content: string) {
let ds = new CompressionStream("gzip");
let blob = new Blob([content]);
let compressedStream = blob.stream().pipeThrough(ds);
return (await new Response(compressedStream).blob()).size;
}
function getSize(content: string) {
return (new TextEncoder().encode(content)).length
}
function minify(value: string) {
return value
.replace(/([^0-9a-zA-Z\.#])\s+/g, "$1")
.replace(/\s([^0-9a-zA-Z\.#]+)/g, "$1")
.replace(/;}/g, "}")
.replace(/\/\*.*?\*\//g, "");
}
}
function applyPreset(p) {
let cs = document.querySelectorAll(".colorselector > *");
cs[0].value = p.main;
cs[1].value = p.text;
cs[2].value = p.bg;
updateColors();
let html = document.querySelector("html");
html.style = "";
if(p.siteBg) html.style.background = p.siteBg;
export interface Preset {
main: string,
text: string,
bg: string,
siteBg?: string
}
class Color {
export class Color {
r;
g;
b;
a;
r: number;
g: number;
b: number;
a: number;
constructor(r, g, b, a = 1) {
constructor(r: number, g: number, b: number, a: number = 1) {
this.r = r;
this.g = g;
this.b = b;
@ -83,11 +99,12 @@ class Color {
return new Color(this.r, this.g, this.b, this.a);
}
static fromHex(hex) {
return new Color(...this.hexToRgb(hex));
static fromHex(hex: string) {
let [r, g, b] = this.hexToRgb(hex);
return new Color(r, g, b);
}
static hexToRgb(hex) {
static hexToRgb(hex: string) {
hex = hex.replace(/^#/, '');
const r = parseInt(hex.slice(0, 2), 16) / 255;
const g = parseInt(hex.slice(2, 4), 16) / 255;
@ -99,8 +116,8 @@ class Color {
return `rgba(${this.r*255}, ${this.g*255}, ${this.b*255}, ${this.a})`;
}
contrast(otherColor) {
const getRelativeLuminance = (rgb) => {
contrast(otherColor: Color) {
const getRelativeLuminance = (rgb: number) => {
const sRGB = rgb / 255;
return sRGB <= 0.03928 ? sRGB / 12.92 : Math.pow((sRGB + 0.055) / 1.055, 2.4);
};
@ -114,19 +131,19 @@ class Color {
return (contrastRatio*100)-100;
}
equals(otherColor) {
equals(otherColor: Color) {
return this.r == otherColor.r && this.g == otherColor.g && this.b == otherColor.b && this.a == otherColor.a;
}
}
class Theme {
export class Theme {
main;
text;
bg;
main: Color;
text: Color;
bg: Color;
constructor(opt) {
constructor(opt: Preset) {
this.main = Color.fromHex(opt.main);
this.text = Color.fromHex(opt.text);
@ -165,16 +182,16 @@ class Theme {
if(this.main.contrast(this.bg) < .3) alert("Contrast between main color and the background is low!");
if(this.bg.contrast(this.text) < .3) alert("Contrast between text color and the background is low!");
let styles = [`:root {${variables.join("")}}`];
let styles = [`:root {\n${variables.join("\n")}\n}`];
let btnColor = this.getBtnColor(this.main, this.text);
if(btnColor != this.text) styles.push(`.btn.btn-primary {${this.var("text-color", btnColor.rgbFormat())}}`);
if(btnColor != this.text) styles.push(`.btn.btn-primary {\n${this.var("text-color", btnColor.rgbFormat())}\n}`);
return styles.join("\n");
return styles.join("\n\n");
}
getBtnColor(main, text) {
getBtnColor(main: Color, text: Color) {
let white = new Color(1, 1, 1);
let black = new Color(0, 0, 0);
let cText = main.contrast(text);
@ -182,14 +199,14 @@ class Theme {
return cWhite > .3 ? white : cText > .3 ? text : black;
}
cArgb(color, alpha = 1) {
cArgb(color: Color, alpha: number = 1) {
let c = color.clone();
c.a = alpha;
return c.rgbFormat();
}
var(name, value) {
return `--synergy-${name}: ${value};`;
var(name: string, value: string) {
return `\t--synergy-${name}: ${value};`;
}
}