diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..278eeb2e --- /dev/null +++ b/web/index.html @@ -0,0 +1,476 @@ + + +
+ + + + + + + + + + + + diff --git a/webshit/litegraph.core.js b/web/lib/litegraph.core.js similarity index 100% rename from webshit/litegraph.core.js rename to web/lib/litegraph.core.js diff --git a/webshit/litegraph.css b/web/lib/litegraph.css similarity index 100% rename from webshit/litegraph.css rename to web/lib/litegraph.css diff --git a/web/scripts/api.js b/web/scripts/api.js new file mode 100644 index 00000000..896c15ca --- /dev/null +++ b/web/scripts/api.js @@ -0,0 +1,36 @@ +class ComfyApi { + async getNodeDefs() { + const resp = await fetch("object_info", { cache: "no-store" }); + return await resp.json(); + } + + async queuePrompt(number, { output, workflow }) { + const body = { + client_id: this.clientId, + prompt: output, + extra_data: { extra_pnginfo: { workflow } }, + }; + + if (number === -1) { + body.front = true; + } else if (number != 0) { + body.number = number; + } + + const res = await fetch("/prompt", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (res.status !== 200) { + throw { + response: await res.text(), + }; + } + } +} + +export const api = new ComfyApi(); diff --git a/web/scripts/app.js b/web/scripts/app.js new file mode 100644 index 00000000..cc97f4d4 --- /dev/null +++ b/web/scripts/app.js @@ -0,0 +1,569 @@ +import { ComfyWidgets } from "./widgets.js"; +import { api } from "./api.js"; +import { defaultGraph } from "./defaultGraph.js"; + +class ComfyDialog { + constructor() { + this.element = document.createElement("div"); + this.element.classList.add("comfy-modal"); + + const content = document.createElement("div"); + content.classList.add("comfy-modal-content"); + this.textElement = document.createElement("p"); + content.append(this.textElement); + + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.textContent = "CLOSE"; + content.append(closeBtn); + closeBtn.onclick = () => this.close(); + + this.element.append(content); + document.body.append(this.element); + } + + close() { + this.element.style.display = "none"; + } + + show(html) { + this.textElement.innerHTML = html; + this.element.style.display = "flex"; + } +} + +class ComfyQueue { + constructor() { + this.element = document.createElement("div"); + } + + async update() { + if (this.element.style.display !== "none") { + await this.load(); + } + } + + async show() { + this.element.style.display = "block"; + await this.load(); + } + + async load() { + const queue = await api.getQueue(); + } + + hide() { + this.element.style.display = "none"; + } +} + + +class ComfyUI { + constructor(app) { + this.app = app; + this.menuContainer = document.createElement("div"); + this.menuContainer.classList.add("comfy-menu"); + document.body.append(this.menuContainer); + + this.dialog = new ComfyDialog(); + this.queue = new ComfyQueue(); + } +} + +class ComfyApp { + constructor() { + this.ui = new ComfyUI(this); + this.nodeOutputs = {}; + this.extensions = [ + { + name: "TestExtension", + init(app) { + console.log("[ext:init]", app); + }, + setup(app) { + console.log("[ext:setup]", app); + }, + addCustomNodeDefs(defs, app) { + console.log("[ext:addCustomNodeDefs]", defs, app); + }, + loadedGraphNode(node, app) { + // console.log("[ext:loadedGraphNode]", node, app); + }, + getCustomWidgets(app) { + console.log("[ext:getCustomWidgets]", app); + return {}; + }, + beforeRegisterNode(nodeType, nodeData, app) { + // console.log("[ext:beforeRegisterNode]", nodeType, nodeData, app); + }, + registerCustomNodes(app) { + console.log("[ext:registerCustomNodes]", app); + }, + }, + ]; + } + + #log(message, ...other) { + console.log("[comfy]", message, ...other); + } + + #error(message, ...other) { + console.error("[comfy]", message, ...other); + } + + #invokeExtensions(method, ...args) { + let results = []; + for (const ext of this.extensions) { + if (method in ext) { + try { + results.push(ext[method](...args, this)); + } catch (error) { + this.#error( + `Error calling extension '${ext.name}' method '${method}'`, + { error }, + { extension: ext }, + { args } + ); + } + } + } + return results; + } + + async #invokeExtensionsAsync(method, ...args) { + return await Promise.all( + this.extensions.map(async (ext) => { + if (method in ext) { + try { + return await ext[method](...args, this); + } catch (error) { + this.#error( + `Error calling extension '${ext.name}' method '${method}'`, + { error }, + { extension: ext }, + { args } + ); + } + } + }) + ); + } + + #addNodeContextMenuHandler(node) { + node.prototype.getExtraMenuOptions = function (_, options) { + if (this.imgs) { + // If this node has images then we add an open in new tab item + let img; + if (this.imageIndex != null) { + // An image is selected so select that + img = this.imgs[this.imageIndex]; + } else if (this.overIndex != null) { + // No image is selected but one is hovered + img = this.imgs[this.overIndex]; + } + if (img) { + options.unshift({ + content: "Open Image", + callback: () => window.open(img.src, "_blank"), + }); + } + } + }; + } + + #addDrawBackgroundHandler(node) { + const app = this; + node.prototype.onDrawBackground = function (ctx) { + if (!this.flags.collapsed) { + const output = app.nodeOutputs[this.id + ""]; + if (output && output.images) { + if (this.images !== output.images) { + this.images = output.images; + this.imgs = null; + this.imageIndex = null; + Promise.all( + output.images.map((src) => { + return new Promise((r) => { + const img = new Image(); + img.onload = () => r(img); + img.onerror = () => r(null); + img.src = "/view/" + src; + }); + }) + ).then((imgs) => { + if (this.images === output.images) { + this.imgs = imgs.filter(Boolean); + if (this.size[1] < 100) { + this.size[1] = 250; + } + app.graph.setDirtyCanvas(true); + } + }); + } + + if (this.imgs) { + const canvas = graph.list_of_graphcanvas[0]; + const mouse = canvas.graph_mouse; + if (!canvas.pointer_is_down && this.pointerDown) { + if (mouse[0] === this.pointerDown.pos[0] && mouse[1] === this.pointerDown.pos[1]) { + this.imageIndex = this.pointerDown.index; + } + this.pointerDown = null; + } + + let w = this.imgs[0].naturalWidth; + let h = this.imgs[0].naturalHeight; + let imageIndex = this.imageIndex; + const numImages = this.imgs.length; + if (numImages === 1 && !imageIndex) { + this.imageIndex = imageIndex = 0; + } + let shiftY = this.type === "SaveImage" ? 55 : 0; + let dw = this.size[0]; + let dh = this.size[1]; + dh -= shiftY; + + if (imageIndex == null) { + let best = 0; + let cellWidth; + let cellHeight; + let cols = 0; + let shiftX = 0; + for (let c = 1; c <= numImages; c++) { + const rows = Math.ceil(numImages / c); + const cW = dw / c; + const cH = dh / rows; + const scaleX = cW / w; + const scaleY = cH / h; + + const scale = Math.min(scaleX, scaleY, 1); + const imageW = w * scale; + const imageH = h * scale; + const area = imageW * imageH * numImages; + + if (area > best) { + best = area; + cellWidth = imageW; + cellHeight = imageH; + cols = c; + shiftX = c * ((cW - imageW) / 2); + } + } + + let anyHovered = false; + this.imageRects = []; + for (let i = 0; i < numImages; i++) { + const img = this.imgs[i]; + const row = Math.floor(i / cols); + const col = i % cols; + const x = col * cellWidth + shiftX; + const y = row * cellHeight + shiftY; + if (!anyHovered) { + anyHovered = LiteGraph.isInsideRectangle( + mouse[0], + mouse[1], + x + this.pos[0], + y + this.pos[1], + cellWidth, + cellHeight + ); + if (anyHovered) { + this.overIndex = i; + let value = 110; + if (canvas.pointer_is_down) { + if (!this.pointerDown || this.pointerDown.index !== i) { + this.pointerDown = { index: i, pos: [...mouse] }; + } + value = 125; + } + ctx.filter = `contrast(${value}%) brightness(${value}%)`; + canvas.canvas.style.cursor = "pointer"; + } + } + this.imageRects.push([x, y, cellWidth, cellHeight]); + ctx.drawImage(img, x, y, cellWidth, cellHeight); + ctx.filter = "none"; + } + + if (!anyHovered) { + this.pointerDown = null; + this.overIndex = null; + } + } else { + // Draw individual + const scaleX = dw / w; + const scaleY = dh / h; + const scale = Math.min(scaleX, scaleY, 1); + + w *= scale; + h *= scale; + + let x = (dw - w) / 2; + let y = (dh - h) / 2 + shiftY; + ctx.drawImage(this.imgs[imageIndex], x, y, w, h); + + const drawButton = (x, y, sz, text) => { + const hovered = LiteGraph.isInsideRectangle( + mouse[0], + mouse[1], + x + this.pos[0], + y + this.pos[1], + sz, + sz + ); + let fill = "#333"; + let textFill = "#fff"; + let isClicking = false; + if (hovered) { + canvas.canvas.style.cursor = "pointer"; + if (canvas.pointer_is_down) { + fill = "#1e90ff"; + isClicking = true; + } else { + fill = "#eee"; + textFill = "#000"; + } + } else { + this.pointerWasDown = null; + } + + ctx.fillStyle = fill; + ctx.beginPath(); + ctx.roundRect(x, y, sz, sz, [4]); + ctx.fill(); + ctx.fillStyle = textFill; + ctx.font = "12px Arial"; + ctx.textAlign = "center"; + ctx.fillText(text, x + 15, y + 20); + + return isClicking; + }; + + if (numImages > 1) { + if (drawButton(x + w - 35, y + h - 35, 30, `${this.imageIndex + 1}/${numImages}`)) { + let i = this.imageIndex + 1 >= numImages ? 0 : this.imageIndex + 1; + if (!this.pointerDown || !this.pointerDown.index === i) { + this.pointerDown = { index: i, pos: [...mouse] }; + } + } + + if (drawButton(x + w - 35, y + 5, 30, `x`)) { + if (!this.pointerDown || !this.pointerDown.index === null) { + this.pointerDown = { index: null, pos: [...mouse] }; + } + } + } + } + } + } + } + }; + } + + /** + * Set up the app on the page + */ + async setup() { + // Create and mount the LiteGraph in the DOM + const canvasEl = Object.assign(document.createElement("canvas"), { id: "graph-canvas" }); + document.body.prepend(canvasEl); + + this.graph = new LGraph(); + const canvas = (this.canvas = new LGraphCanvas(canvasEl, this.graph)); + this.ctx = canvasEl.getContext("2d"); + + this.graph.start(); + + function resizeCanvas() { + canvasEl.width = canvasEl.offsetWidth; + canvasEl.height = canvasEl.offsetHeight; + canvas.draw(true, true); + } + + // Ensure the canvas fills the window + resizeCanvas(); + window.addEventListener("resize", resizeCanvas); + + await this.#invokeExtensionsAsync("init"); + await this.registerNodes(); + + // Load previous workflow + let restored = false; + try { + const json = localStorage.getItem("workflow"); + if (json) { + const workflow = JSON.parse(json); + this.loadGraphData(workflow); + restored = true; + } + } catch (err) {} + + // We failed to restore a workflow so load the default + if (!restored) { + this.loadGraphData(defaultGraph); + } + + // Save current workflow automatically + setInterval(() => localStorage.setItem("workflow", JSON.stringify(this.graph.serialize())), 1000); + + await this.#invokeExtensionsAsync("setup"); + } + + async registerNodes() { + const app = this; + // Load node definitions from the backend + const defs = await api.getNodeDefs(); + await this.#invokeExtensionsAsync("addCustomNodeDefs", defs); + + // Generate list of known widgets + const widgets = Object.assign( + {}, + ComfyWidgets, + ...(await this.#invokeExtensionsAsync("getCustomWidgets")).filter(Boolean) + ); + + // Register a node for each definition + for (const nodeId in defs) { + const nodeData = defs[nodeId]; + const node = Object.assign( + function ComfyNode() { + const inputs = nodeData["input"]["required"]; + const config = { minWidth: 1, minHeight: 1 }; + for (const inputName in inputs) { + const inputData = inputs[inputName]; + const type = inputData[0]; + + if (Array.isArray(type)) { + // Enums e.g. latent rotation + this.addWidget("combo", inputName, type[0], () => {}, { values: type }); + } else if (`${type}:${inputName}` in widgets) { + // Support custom widgets by Type:Name + Object.assign(config, widgets[`${type}:${inputName}`](this, inputName, inputData, app) || {}); + } else if (type in widgets) { + // Standard type widgets + Object.assign(config, widgets[type](this, inputName, inputData, app) || {}); + } else { + // Node connection inputs + this.addInput(inputName, type); + } + } + + const s = this.computeSize(); + s[0] = Math.max(config.minWidth, s[0] * 1.5); + s[1] = Math.max(config.minHeight, s[1]); + this.size = s; + this.serialize_widgets = true; + }, + { + title: nodeData.name, + comfyClass: nodeData.name, + } + ); + node.prototype.comfyClass = nodeData.name; + + this.#addNodeContextMenuHandler(node); + this.#addDrawBackgroundHandler(node, app); + + await this.#invokeExtensionsAsync("beforeRegisterNode", node, nodeData); + LiteGraph.registerNodeType(nodeId, node); + node.category = nodeData.category; + } + + await this.#invokeExtensionsAsync("registerCustomNodes"); + } + + /** + * Populates the graph with the specified workflow data + * @param {*} graphData A serialized graph object + */ + loadGraphData(graphData) { + this.graph.configure(graphData); + + for (const node of this.graph._nodes) { + const size = node.computeSize(); + size[0] = Math.max(node.size[0], size[0]); + size[1] = Math.max(node.size[1], size[1]); + node.size = size; + + if (node.widgets) { + // If you break something in the backend and want to patch workflows in the frontend + // This is the place to do this + for (let widget of node.widgets) { + if (node.type == "KSampler" || node.type == "KSamplerAdvanced") { + if (widget.name == "sampler_name") { + if (widget.value.startsWith("sample_")) { + wid.value = widget.value.slice(7); + } + } + } + } + } + + this.#invokeExtensions("loadedGraphNode", node); + } + } + + graphToPrompt() { + // TODO: Implement dynamic prompts + const workflow = this.graph.serialize(); + const output = {}; + for (const n of workflow.nodes) { + const inputs = {}; + const node = this.graph.getNodeById(n.id); + const widgets = node.widgets; + + // Store all widget values + if (widgets) { + for (const widget of widgets) { + if (widget.options.serialize !== false) { + inputs[widget.name] = widget.value; + } + } + } + + // Store all node links + for (let i in node.inputs) { + const link = node.getInputLink(i); + if (link) { + inputs[node.inputs[i].name] = [String(link.origin_id), parseInt(link.origin_slot)]; + } + } + + output[String(node.id)] = { + inputs, + class_type: node.comfyClass, + }; + } + + return { workflow, output }; + } + + async queuePrompt(number) { + const p = this.graphToPrompt(); + + try { + await api.queuePrompt(number, p); + } catch (error) { + this.ui.dialog.show(error.response || error.toString()); + return; + } + + for (const n of p.workflow.nodes) { + const node = graph.getNodeById(n.id); + if (node.widgets) { + for (const widget of node.widgets) { + // Allow widgets to run callbacks after a prompt has been queued + // e.g. random seed after every gen + if (widget.afterQueued) { + widget.afterQueued(); + } + } + } + } + + this.canvas.draw(true, true); + await this.ui.queue.update(); + } +} + +export const app = new ComfyApp(); diff --git a/web/scripts/defaultGraph.js b/web/scripts/defaultGraph.js new file mode 100644 index 00000000..865f1ca0 --- /dev/null +++ b/web/scripts/defaultGraph.js @@ -0,0 +1,119 @@ +export const defaultGraph = { + last_node_id: 9, + last_link_id: 9, + nodes: [ + { + id: 7, + type: "CLIPTextEncode", + pos: [413, 389], + size: { 0: 425.27801513671875, 1: 180.6060791015625 }, + flags: {}, + order: 3, + mode: 0, + inputs: [{ name: "clip", type: "CLIP", link: 5 }], + outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [6], slot_index: 0 }], + properties: {}, + widgets_values: ["bad hands"], + }, + { + id: 6, + type: "CLIPTextEncode", + pos: [415, 186], + size: { 0: 422.84503173828125, 1: 164.31304931640625 }, + flags: {}, + order: 2, + mode: 0, + inputs: [{ name: "clip", type: "CLIP", link: 3 }], + outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [4], slot_index: 0 }], + properties: {}, + widgets_values: ["masterpiece best quality girl"], + }, + { + id: 5, + type: "EmptyLatentImage", + pos: [473, 609], + size: { 0: 315, 1: 106 }, + flags: {}, + order: 1, + mode: 0, + outputs: [{ name: "LATENT", type: "LATENT", links: [2], slot_index: 0 }], + properties: {}, + widgets_values: [512, 512, 1], + }, + { + id: 3, + type: "KSampler", + pos: [863, 186], + size: { 0: 315, 1: 262 }, + flags: {}, + order: 4, + mode: 0, + inputs: [ + { name: "model", type: "MODEL", link: 1 }, + { name: "positive", type: "CONDITIONING", link: 4 }, + { name: "negative", type: "CONDITIONING", link: 6 }, + { name: "latent_image", type: "LATENT", link: 2 }, + ], + outputs: [{ name: "LATENT", type: "LATENT", links: [7], slot_index: 0 }], + properties: {}, + widgets_values: [8566257, true, 20, 8, "euler", "normal", 1], + }, + { + id: 8, + type: "VAEDecode", + pos: [1209, 188], + size: { 0: 210, 1: 46 }, + flags: {}, + order: 5, + mode: 0, + inputs: [ + { name: "samples", type: "LATENT", link: 7 }, + { name: "vae", type: "VAE", link: 8 }, + ], + outputs: [{ name: "IMAGE", type: "IMAGE", links: [9], slot_index: 0 }], + properties: {}, + }, + { + id: 9, + type: "SaveImage", + pos: [1451, 189], + size: { 0: 210, 1: 26 }, + flags: {}, + order: 6, + mode: 0, + inputs: [{ name: "images", type: "IMAGE", link: 9 }], + properties: {}, + }, + { + id: 4, + type: "CheckpointLoader", + pos: [26, 474], + size: { 0: 315, 1: 122 }, + flags: {}, + order: 0, + mode: 0, + outputs: [ + { name: "MODEL", type: "MODEL", links: [1], slot_index: 0 }, + { name: "CLIP", type: "CLIP", links: [3, 5], slot_index: 1 }, + { name: "VAE", type: "VAE", links: [8], slot_index: 2 }, + ], + properties: {}, + widgets_values: ["v1-inference.yaml", "v1-5-pruned-emaonly.ckpt"], + }, + ], + links: [ + [1, 4, 0, 3, 0, "MODEL"], + [2, 5, 0, 3, 3, "LATENT"], + [3, 4, 1, 6, 0, "CLIP"], + [4, 6, 0, 3, 1, "CONDITIONING"], + [5, 4, 1, 7, 0, "CLIP"], + [6, 7, 0, 3, 2, "CONDITIONING"], + [7, 3, 0, 8, 0, "LATENT"], + [8, 4, 2, 8, 1, "VAE"], + [9, 8, 0, 9, 0, "IMAGE"], + ], + groups: [], + config: {}, + extra: {}, + version: 0.4, +}; diff --git a/web/scripts/widgets.js b/web/scripts/widgets.js new file mode 100644 index 00000000..1177f82f --- /dev/null +++ b/web/scripts/widgets.js @@ -0,0 +1,118 @@ +function getNumberDefaults(inputData, defaultStep) { + let defaultVal = inputData[1]["default"]; + let { min, max, step } = inputData[1]; + + if (defaultVal == undefined) defaultVal = 0; + if (min == undefined) min = 0; + if (max == undefined) max = 2048; + if (step == undefined) step = defaultStep; + + return { val: defaultVal, config: { min, max, step: 10.0 * step } }; +} + +function seedWidget(node, inputName, inputData) { + const seed = ComfyWidgets.INT(node, inputName, inputData); + const randomize = node.addWidget("toggle", "Random seed after every gen", true, function (v) {}, { + on: "enabled", + off: "disabled", + serialize: false, // Don't include this in prompt. + }); + + randomize.afterQueued = () => { + if (randomize.value) { + seed.widget.value = Math.floor(Math.random() * 1125899906842624); + } + }; + + return { widget: seed, randomize }; +} + +function addMultilineWidget(node, name, defaultVal, dynamicPrompt, app) { + const widget = { + type: "customtext", + name, + get value() { + return this.inputEl.value; + }, + set value(x) { + this.inputEl.value = x; + }, + options: { + dynamicPrompt, + }, + draw: function (ctx, _, widgetWidth, y, widgetHeight) { + const visible = app.canvas.ds.scale > 0.5; + const t = ctx.getTransform(); + const margin = 10; + console.log("back you go") + Object.assign(this.inputEl.style, { + left: `${t.a * margin + t.e}px`, + top: `${t.d * (y + widgetHeight - margin) + t.f}px`, + width: `${(widgetWidth - margin * 2 - 3) * t.a}px`, + height: `${(this.parent.size[1] - (y + widgetHeight) - 3) * t.d}px`, + position: "absolute", + zIndex: 1, + fontSize: `${t.d * 10.0}px`, + }); + this.inputEl.hidden = !visible; + }, + }; + widget.inputEl = document.createElement("textarea"); + widget.inputEl.className = "comfy-multiline-input"; + widget.inputEl.value = defaultVal; + document.addEventListener("click", function (event) { + if (!widget.inputEl.contains(event.target)) { + widget.inputEl.blur(); + } + }); + widget.parent = node; + document.body.appendChild(widget.inputEl); + + node.addCustomWidget(widget); + + node.onRemoved = function () { + // When removing this node we need to remove the input from the DOM + for (let y in this.widgets) { + if (this.widgets[y].inputEl) { + this.widgets[y].inputEl.remove(); + } + } + }; + + return { minWidth: 400, minHeight: 200, widget }; +} + +export const ComfyWidgets = { + "INT:seed": seedWidget, + "INT:noise_seed": seedWidget, + FLOAT(node, inputName, inputData) { + const { val, config } = getNumberDefaults(inputData, 0.5); + return { widget: node.addWidget("number", inputName, val, () => {}, config) }; + }, + INT(node, inputName, inputData) { + const { val, config } = getNumberDefaults(inputData, 1); + return { + widget: node.addWidget( + "number", + inputName, + val, + function (v) { + const s = this.options.step / 10; + this.value = Math.round(v / s) * s; + }, + config + ), + }; + }, + STRING(node, inputName, inputData, app) { + const defaultVal = inputData[1].default || ""; + const multiline = !!inputData[1].multiline; + const dynamicPrompt = !!inputData[1].dynamic_prompt; + + if (multiline) { + return addMultilineWidget(node, inputName, defaultVal, dynamicPrompt, app); + } else { + return { widget: node.addWidget("text", inputName, defaultVal, () => {}, { dynamicPrompt }) }; + } + }, +}; diff --git a/web/style.css b/web/style.css new file mode 100644 index 00000000..37344788 --- /dev/null +++ b/web/style.css @@ -0,0 +1,81 @@ +body { + width: 100vw; + height: 100vh; + margin: 0; + overflow: hidden; +} + +#graph-canvas { + width: 100%; + height: 100%; +} + +.comfy-multiline-input { + background-color: #ffffff; + overflow: hidden; + overflow-y: auto; + padding: 2px; + resize: none; + border: none; +} + +.comfy-modal { + display: none; /* Hidden by default */ + position: fixed; /* Stay in place */ + z-index: 100; /* Sit on top */ + padding: 30px 30px 10px 30px; + background-color: #ff0000; /* Modal background */ + box-shadow: 0px 0px 20px #888888; + border-radius: 10px; + text-align: center; + top: 50%; + left: 50%; + max-width: 80vw; + max-height: 80vh; + transform: translate(-50%, -50%); + overflow: hidden; +} + +.comfy-modal-content { + display: flex; + flex-direction: column; +} + +.comfy-modal p { + overflow: auto; + white-space: pre-line; /* This will respect line breaks */ + margin-bottom: 20px; /* Add some margin between the text and the close button*/ +} + +.comfy-modal button { + cursor: pointer; + color: #aaaaaa; + border: none; + background-color: transparent; + font-size: 24px; + font-weight: bold; + width: 100%; +} + +.comfy-modal button:hover, +.comfy-modal button:focus { + color: #000; + text-decoration: none; + cursor: pointer; +} + +@media (prefers-color-scheme: dark) { + body { + background-color: #202020; + } + .comfy-multiline-input { + background-color: #202020; + color: white; + } +} + +@media only screen and (max-height: 850px) { + #menu { + margin-top: -70px; + } +} diff --git a/webshit/index.html b/webshit/index.html deleted file mode 100644 index 410fb744..00000000 --- a/webshit/index.html +++ /dev/null @@ -1,1117 +0,0 @@ - - - - - - - - -