diff --git a/manifest-generator/.prettierrc.json b/manifest-generator/.prettierrc.json
new file mode 100644
index 0000000..3626b62
--- /dev/null
+++ b/manifest-generator/.prettierrc.json
@@ -0,0 +1,11 @@
+{
+ "trailingComma": "es5",
+ "tabWidth": 2,
+ "semi": true,
+ "printWidth": 80,
+ "singleQuote": false,
+ "bracketSpacing": true,
+ "bracketSameLine": false,
+ "arrowParens": "always",
+ "singleAttributePerLine": false
+}
diff --git a/manifest-generator/app.js b/manifest-generator/app.js
new file mode 100644
index 0000000..0363299
--- /dev/null
+++ b/manifest-generator/app.js
@@ -0,0 +1,17 @@
+import { readManifestFromLocalStorage } from "./state.js";
+
+const registerServiceWorker = async () => {
+ try {
+ await navigator.serviceWorker.register("sw.js");
+ } catch (e) {
+ console.log(`Registration failed: ${e}`);
+ }
+};
+
+if (navigator.serviceWorker) {
+ registerServiceWorker();
+}
+
+// Grab previous state from Local Storage so that progress is not lost
+// across sessions.
+readManifestFromLocalStorage();
diff --git a/manifest-generator/components/app-view.js b/manifest-generator/components/app-view.js
new file mode 100644
index 0000000..7401360
--- /dev/null
+++ b/manifest-generator/components/app-view.js
@@ -0,0 +1,87 @@
+// A split app-view to show the manifest viewer in the right pane and the editor in the left pane.
+
+import "./manifest-view/index.js";
+import "./navigation-view.js";
+import "./page-view.js";
+import "./styled-button.js";
+
+const template = document.createElement("template");
+template.innerHTML = `
+
+
+
+
+
+ Page 1
+
+
+ Page 2
+
+
+
+
+`;
+
+class AppView extends HTMLElement {
+ constructor() {
+ super();
+
+ this.attachShadow({ mode: "open" });
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
+ this.navigationView = this.shadowRoot.querySelector("navigation-view");
+
+ this.pageIds = ["page-1", "page-2"];
+ this.currentPageIdIndex = 0;
+
+ this.navigationView.addEventListener("next", () => this.nextPage());
+ this.navigationView.addEventListener("prev", () => this.prevPage());
+ this.navigationView.addEventListener("skip", () => this.skipPage());
+ }
+
+ nextPage() {
+ this.jumpToPage(
+ Math.min(this.currentPageIdIndex + 1, this.pageIds.length - 1)
+ );
+ }
+
+ prevPage() {
+ this.jumpToPage(Math.max(this.currentPageIdIndex - 1, 0));
+ }
+
+ skipPage() {
+ this.jumpToPage(
+ Math.min(this.currentPageIdIndex + 1, this.pageIds.length - 1)
+ );
+ }
+
+ jumpToPage(pageIndex) {
+ this.currentPageIdIndex = pageIndex;
+ this.navigationView.setAttribute(
+ "current-id",
+ this.pageIds[this.currentPageIdIndex]
+ );
+ }
+}
+
+customElements.define("app-view", AppView);
diff --git a/manifest-generator/components/color-picker.js b/manifest-generator/components/color-picker.js
new file mode 100644
index 0000000..0e5bc0c
--- /dev/null
+++ b/manifest-generator/components/color-picker.js
@@ -0,0 +1,58 @@
+// Component for a color picker -- a text label and a color input box.
+/*
+ Usage:
+
+*/
+class ColorPicker extends HTMLElement {
+ #inputElement;
+ constructor() {
+ super();
+ this.#inputElement = document.createElement("input");
+
+ // Create a shadow root
+ const shadow = this.attachShadow({ mode: "open" });
+
+ const tableWrapper = document.createElement("div");
+ tableWrapper.setAttribute("class", "table");
+
+ // Create the page label
+ const inputLabel = document.createElement("p");
+ inputLabel.setAttribute("class", "tableitem");
+ inputLabel.textContent = `${this.getAttribute("label")}`;
+
+ // Create the input element
+ this.#inputElement.setAttribute("type", "color");
+ this.#inputElement.setAttribute("class", "tableitem");
+
+ // Style the elements
+ const style = document.createElement("style");
+ style.textContent = `.tableitem {
+ align-self: center;
+ }
+
+ .table {
+ display: flex;
+ flex-direction: column;
+ }`;
+
+ const stylesheet = document.createElement("link");
+ stylesheet.setAttribute("rel", "stylesheet");
+ stylesheet.setAttribute("href", "styles/defaults.css");
+
+ // Append the text and input elements to the table
+ tableWrapper.append(inputLabel);
+ tableWrapper.append(this.#inputElement);
+
+ // Append the table and style to the shadow DOM
+ shadow.append(tableWrapper);
+ shadow.append(style);
+ shadow.append(stylesheet);
+ }
+
+ getUserInput() {
+ return this.#inputElement.value;
+ }
+}
+
+// Define the new element
+customElements.define("color-picker", ColorPicker);
diff --git a/manifest-generator/components/display-mode.js b/manifest-generator/components/display-mode.js
new file mode 100644
index 0000000..e4c505e
--- /dev/null
+++ b/manifest-generator/components/display-mode.js
@@ -0,0 +1,111 @@
+// Component for a images as radio buttons.
+/*
+ Usage:
+
+
+*/
+
+const template = document.createElement("template");
+template.innerHTML = `
+
+
+
+
+`;
+
+class DisplayMode extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
+ }
+
+ getUserInput() {
+ const inputElement = this.shadowRoot.querySelector(
+ "input[type='radio']:checked"
+ );
+ if (inputElement) {
+ return inputElement.value;
+ }
+ return;
+ }
+}
+
+// Define the new element
+customElements.define("display-mode", DisplayMode);
diff --git a/manifest-generator/components/long-text-input-example.html b/manifest-generator/components/long-text-input-example.html
new file mode 100644
index 0000000..7a97d12
--- /dev/null
+++ b/manifest-generator/components/long-text-input-example.html
@@ -0,0 +1,18 @@
+
+
+
+
+ Simple word count web component
+
+
+
+
+
+
+
+
+
+
diff --git a/manifest-generator/components/long-text-input.js b/manifest-generator/components/long-text-input.js
new file mode 100644
index 0000000..21c7748
--- /dev/null
+++ b/manifest-generator/components/long-text-input.js
@@ -0,0 +1,70 @@
+// Component for a long text input box. Optional attributes for a label and placeholder text.
+// See long-text-input-example.html for usage examples.
+
+class LongTextInput extends HTMLElement {
+ #inputElement;
+ constructor() {
+ super();
+
+ // Create a shadow root
+ const shadow = this.attachShadow({ mode: "open" });
+
+ const tableWrapper = document.createElement("div");
+ tableWrapper.setAttribute("class", "table");
+
+ // Create the page label
+ const inputLabel = document.createElement("p");
+ inputLabel.setAttribute("class", "table-item");
+ if (this.getAttribute("label")) {
+ inputLabel.textContent = `${this.getAttribute("label")}`;
+ } else {
+ inputLabel.setAttribute("hidden", true);
+ }
+
+ // Create the input element
+ this.#inputElement = document.createElement("textarea");
+ this.#inputElement.setAttribute("class", "table-item");
+ if (this.getAttribute("placeholder-text")) {
+ this.#inputElement.setAttribute(
+ "placeholder",
+ `${this.getAttribute("placeholder-text")}`
+ );
+ }
+
+ // Style the elements
+ const style = document.createElement("style");
+ style.textContent = `.table-item {
+ align-self: center;
+ }
+
+ .table {
+ display: flex;
+ flex-direction: column;
+ }`;
+
+ const stylesheetDefault = document.createElement("link");
+ stylesheetDefault.setAttribute("rel", "stylesheet");
+ stylesheetDefault.setAttribute("href", "../styles/defaults.css");
+
+ const stylesheetInput = document.createElement("link");
+ stylesheetInput.setAttribute("rel", "stylesheet");
+ stylesheetInput.setAttribute("href", "../styles/input.css");
+
+ // Append the text and input elements to the table
+ tableWrapper.append(inputLabel);
+ tableWrapper.append(this.#inputElement);
+
+ // Append the table and style to the shadow DOM
+ shadow.append(tableWrapper);
+ shadow.append(style);
+ shadow.append(stylesheetDefault);
+ shadow.append(stylesheetInput);
+ }
+
+ getUserInput() {
+ return this.#inputElement.value;
+ }
+}
+
+// Define the new element
+customElements.define("long-text-input", LongTextInput);
diff --git a/manifest-generator/components/manifest-view/index.js b/manifest-generator/components/manifest-view/index.js
new file mode 100644
index 0000000..a05af49
--- /dev/null
+++ b/manifest-generator/components/manifest-view/index.js
@@ -0,0 +1,54 @@
+import JSONView from "./json.js";
+
+const json = {
+ name: "manifest-generator",
+ short_name: "manifest-generator",
+ start_url: "/",
+ display: "standalone",
+ background_color: "#fff",
+ theme_color: "#fff",
+ description: "A simple tool to generate a web app manifest",
+ icons: [
+ {
+ src: "https://manifest-gen.now.sh/static/icon-192x192.png",
+ sizes: "192x192",
+ type: "image/png",
+ },
+ {
+ src: "https://manifest-gen.now.sh/static/icon-512x512.png",
+ sizes: "512x512",
+ type: "image/png",
+ },
+ ],
+};
+
+// create a web component
+class ManifestView extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this.shadowRoot.innerHTML = `
+
+
+
+
Manifest
+
+
+ `;
+ }
+}
+
+// create a web component
+customElements.define("manifest-view", ManifestView);
+
+// export to use in other files
+export default ManifestView;
diff --git a/manifest-generator/components/manifest-view/json-array.js b/manifest-generator/components/manifest-view/json-array.js
new file mode 100644
index 0000000..921fe88
--- /dev/null
+++ b/manifest-generator/components/manifest-view/json-array.js
@@ -0,0 +1,53 @@
+const template = document.createElement("template");
+template.innerHTML = `
+
+
+`;
+
+class JSONArray extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
+ }
+
+ connectedCallback() {
+ const jsonValue = this.getAttribute("json");
+ this.json = JSON.parse(decodeURIComponent(jsonValue));
+ this.render();
+ }
+
+ disconnectedCallback() {}
+
+ render() {
+ const jsonValue = this.json;
+ const arrayNode = this.shadowRoot.querySelector(".node");
+ for (let json of jsonValue) {
+ const node = document.createElement("json-view");
+ node.setAttribute("json", encodeURIComponent(JSON.stringify(json)));
+ arrayNode.appendChild(node);
+ }
+ }
+
+ observedAttributes() {
+ return ["json"];
+ }
+}
+
+customElements.define("json-array", JSONArray);
+
+export default JSONArray;
diff --git a/manifest-generator/components/manifest-view/json.js b/manifest-generator/components/manifest-view/json.js
new file mode 100644
index 0000000..e2e7fda
--- /dev/null
+++ b/manifest-generator/components/manifest-view/json.js
@@ -0,0 +1,83 @@
+import "./node.js";
+
+// Define a custom element for representing a JSON document
+const template = document.createElement("template");
+template.innerHTML = `
+
+
+
+`;
+
+class JSONView extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
+ }
+
+ connectedCallback() {
+ const jsonValue = this.getAttribute("json");
+ this.json = JSON.parse(decodeURIComponent(jsonValue));
+
+ this.render();
+ }
+
+ render() {
+ const jsonView = this.shadowRoot.querySelector(".json");
+ jsonView.addEventListener("click", (e) => {
+ const isCollapsed = jsonView.getAttribute("collapsed") !== null;
+ jsonView.toggleAttribute("collapsed");
+ if (isCollapsed) {
+ jsonView.innerHTML = "";
+ this.renderNodes(jsonView, this.json);
+ } else {
+ jsonView.innerHTML = "...";
+ }
+ e.stopPropagation();
+ });
+
+ this.renderNodes(jsonView, this.json);
+ }
+
+ renderNodes(jsonView, json) {
+ Object.keys(json).forEach((key) => {
+ const node = document.createElement("json-node");
+ var nodeType = typeof json[key];
+ // find if object is an array
+ if (Array.isArray(json[key])) {
+ nodeType = "array";
+ }
+ node.setAttribute("type", nodeType);
+ // if the value is an object, add values recursively
+ if (nodeType === "object" || nodeType === "array") {
+ node.setAttribute("key", key);
+ node.setAttribute(
+ "value",
+ encodeURIComponent(JSON.stringify(json[key]))
+ );
+ jsonView.appendChild(node);
+ return;
+ }
+ node.setAttribute("key", key);
+ node.setAttribute("value", json[key]);
+ jsonView.appendChild(node);
+ });
+ }
+}
+
+customElements.define("json-view", JSONView);
+
+export default JSONView;
diff --git a/manifest-generator/components/manifest-view/node.js b/manifest-generator/components/manifest-view/node.js
new file mode 100644
index 0000000..ae4345b
--- /dev/null
+++ b/manifest-generator/components/manifest-view/node.js
@@ -0,0 +1,84 @@
+import "./json-array.js";
+
+// Define a custom element for representing a JSON node
+const template = document.createElement("template");
+template.innerHTML = `
+
+
+
+
+
+
+`;
+class Node extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
+ }
+
+ connectedCallback() {
+ this.key = this.getAttribute("key");
+ this.value = this.getAttribute("value");
+ this.render();
+ }
+
+ observedAttributes() {
+ return ["key", "value"];
+ }
+
+ render() {
+ const node = this.shadowRoot.querySelector(".node");
+
+ const type = this.getAttribute("type");
+ const key = this.shadowRoot.querySelector(".key");
+ const value = this.shadowRoot.querySelector(".value");
+ key.textContent = `"${this.key}" : `;
+ if (type === "object" || type === "array") {
+ node.addEventListener("click", (e) => {
+ const isCollapsed = node.getAttribute("collapsed") !== null;
+ node.toggleAttribute("collapsed");
+ if (!isCollapsed) {
+ value.innerHTML = "...";
+ } else {
+ this.renderValue(value, type, this.value);
+ }
+ e.stopPropagation();
+ });
+ }
+ this.renderValue(value, type, this.value);
+ }
+
+ renderValue(element, type, value) {
+ if (type === "array") {
+ element.innerHTML = ` `;
+ return;
+ }
+ if (type === "object") {
+ element.innerHTML = ` `;
+ return;
+ }
+ element.textContent = value;
+ }
+}
+
+customElements.define("json-node", Node);
+
+export default Node;
diff --git a/manifest-generator/components/navigation-view.js b/manifest-generator/components/navigation-view.js
new file mode 100644
index 0000000..0d08ab8
--- /dev/null
+++ b/manifest-generator/components/navigation-view.js
@@ -0,0 +1,129 @@
+import "./styled-button.js";
+
+const template = document.createElement("template");
+template.innerHTML = `
+
+
+
+
+
+
+
+ prev
+ skip
+ next
+
+
+`;
+
+const attributes = {
+ currentId: {
+ name: "current-id",
+ required: true,
+ },
+ pageSelector: {
+ name: "page-selector",
+ required: true,
+ },
+};
+
+class NavigationView extends HTMLElement {
+ constructor() {
+ super();
+ const shadow = this.attachShadow({ mode: "open" });
+ shadow.append(template.content.cloneNode(true));
+ this.prevButton = this.shadowRoot.querySelector("#prev");
+ this.nextButton = this.shadowRoot.querySelector("#next");
+ this.skipButton = this.shadowRoot.querySelector("#skip");
+
+ this.prevButton.addEventListener("click", () => {
+ this.shadowRoot.dispatchEvent(
+ new CustomEvent("prev", {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+
+ this.nextButton.addEventListener("click", () => {
+ this.shadowRoot.dispatchEvent(
+ new CustomEvent("next", {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+
+ this.skipButton.addEventListener("click", () => {
+ this.shadowRoot.dispatchEvent(
+ new CustomEvent("skip", {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+
+ this.shadowRoot.querySelector("slot").addEventListener("slotchange", () => {
+ this.togglePage(this.currentId);
+ });
+ }
+
+ static get observedAttributes() {
+ return Object.values(attributes).map((opt) => opt.name);
+ }
+
+ // This method doesn't validate any form inputs, just html attributes.
+ validateAttributes(changedValue) {
+ Object.entries(attributes).forEach(([field, opts]) => {
+ if (changedValue !== undefined && opts.name !== changedValue) return;
+ const attribute = this.getAttribute(opts.name);
+ if (opts.required && !attribute)
+ throw new Error(
+ `Attribute ${opts.name} should be set in component ${this.tagName}`
+ );
+ this[field] = attribute;
+ });
+ }
+
+ togglePage(id) {
+ const pages = this.querySelectorAll(this.pageSelector);
+ pages.forEach((page) => {
+ page.toggleAttribute("hidden", page.pageId !== id);
+ });
+ }
+
+ connectedCallback() {
+ this.validateAttributes();
+ }
+
+ attributeChangedCallback(attr, oldVal, newVal) {
+ this.validateAttributes();
+ if (oldVal !== null && attr == attributes.currentId.name) {
+ this.togglePage(newVal);
+ }
+ }
+}
+
+customElements.define("navigation-view", NavigationView);
diff --git a/manifest-generator/components/page-view-example.html b/manifest-generator/components/page-view-example.html
new file mode 100644
index 0000000..3430fb3
--- /dev/null
+++ b/manifest-generator/components/page-view-example.html
@@ -0,0 +1,28 @@
+
+
+
+
+ PageView example page
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/manifest-generator/components/page-view.js b/manifest-generator/components/page-view.js
new file mode 100644
index 0000000..8f92fbf
--- /dev/null
+++ b/manifest-generator/components/page-view.js
@@ -0,0 +1,37 @@
+// Component for a page wrapper -- includes an h1 for page title and a slot for each custom component.
+// See page-view-example.html for usage eaxmple.
+// Has a public API `getId()` that returns this page's unique ID, set via page-id attribute.
+
+class PageView extends HTMLElement {
+ #id;
+
+ constructor() {
+ super();
+
+ const pageViewTemplate = document.createElement("template");
+ pageViewTemplate.innerHTML = `
+
+
+
+ ${this.getAttribute("title")}
+ My Default Text `;
+
+ // Create a shadow root
+ this.attachShadow({ mode: "open" });
+ this.shadowRoot.appendChild(pageViewTemplate.content.cloneNode(true));
+
+ // Set the id field based on the id attribute
+ this.#id = this.getAttribute("page-id");
+ }
+
+ get pageId() {
+ return this.#id;
+ }
+}
+
+// Define the new element
+customElements.define("page-view", PageView);
diff --git a/manifest-generator/components/placeholder-component.js b/manifest-generator/components/placeholder-component.js
new file mode 100644
index 0000000..40bd22c
--- /dev/null
+++ b/manifest-generator/components/placeholder-component.js
@@ -0,0 +1 @@
+// This is just to show folder structure and will be deleted later.
diff --git a/manifest-generator/components/simple-text-input-example.html b/manifest-generator/components/simple-text-input-example.html
new file mode 100644
index 0000000..d578adc
--- /dev/null
+++ b/manifest-generator/components/simple-text-input-example.html
@@ -0,0 +1,18 @@
+
+
+
+
+ Simple word count web component
+
+
+
+
+
+
+
+
+
+
diff --git a/manifest-generator/components/simple-text-input.js b/manifest-generator/components/simple-text-input.js
new file mode 100644
index 0000000..69c9f91
--- /dev/null
+++ b/manifest-generator/components/simple-text-input.js
@@ -0,0 +1,71 @@
+// Component for a simple text input field. Optional attributes for a label and placeholder text.
+// See simple-text-input-example.html for usage examples.
+
+class SimpleTextInput extends HTMLElement {
+ #inputElement;
+ constructor() {
+ super();
+
+ // Create a shadow root
+ const shadow = this.attachShadow({ mode: "open" });
+
+ const tableWrapper = document.createElement("div");
+ tableWrapper.setAttribute("class", "table");
+
+ // Create the page label
+ const inputLabel = document.createElement("p");
+ inputLabel.setAttribute("class", "table-item");
+ if (this.getAttribute("label")) {
+ inputLabel.textContent = `${this.getAttribute("label")}`;
+ } else {
+ inputLabel.setAttribute("hidden", true);
+ }
+
+ // Create the input element
+ this.#inputElement = document.createElement("input");
+ this.#inputElement.setAttribute("class", "table-item");
+ if (this.getAttribute("placeholder-text")) {
+ this.#inputElement.setAttribute(
+ "placeholder",
+ `${this.getAttribute("placeholder-text")}`
+ );
+ }
+
+ // Style the elements
+ const style = document.createElement("style");
+ style.textContent = `.table-item {
+ align-self: center;
+ }
+
+ .table {
+ display: flex;
+ flex-direction: column;
+ }`;
+
+ // Import stylesheets
+ const stylesheetDefault = document.createElement("link");
+ stylesheetDefault.setAttribute("rel", "stylesheet");
+ stylesheetDefault.setAttribute("href", "../styles/defaults.css");
+
+ const stylesheetInput = document.createElement("link");
+ stylesheetInput.setAttribute("rel", "stylesheet");
+ stylesheetInput.setAttribute("href", "../styles/input.css");
+
+ // Append the text and input elements to the table
+ tableWrapper.append(inputLabel);
+ tableWrapper.append(this.#inputElement);
+
+ // Append the table and style to the shadow DOM
+ shadow.append(tableWrapper);
+ shadow.append(style);
+ shadow.append(stylesheetDefault);
+ shadow.append(stylesheetInput);
+ }
+
+ getUserInput() {
+ return this.#inputElement.value;
+ }
+}
+
+// Define the new element
+customElements.define("simple-text-input", SimpleTextInput);
diff --git a/manifest-generator/components/styled-button.js b/manifest-generator/components/styled-button.js
new file mode 100644
index 0000000..244a963
--- /dev/null
+++ b/manifest-generator/components/styled-button.js
@@ -0,0 +1,63 @@
+const template = document.createElement("template");
+template.innerHTML = `
+
+
+
+`;
+
+const attributes = {
+ // type: "primary" | "secondary"
+ type: {
+ name: "type",
+ required: false,
+ allowedValues: ["primary", "secondary"],
+ default: "primary",
+ },
+};
+
+class StyledButton extends HTMLElement {
+ constructor() {
+ super();
+ const shadow = this.attachShadow({ mode: "open" });
+ shadow.append(template.content.cloneNode(true));
+ this.button = this.shadowRoot.querySelector("button");
+ }
+
+ static get observedAttributes() {
+ return Object.values(attributes).map((opt) => opt.name);
+ }
+
+ // This method doesn't validate any form inputs, just html attributes.
+ validateAttributes(changedValue) {
+ Object.entries(attributes).forEach(([field, opts]) => {
+ if (changedValue !== undefined && opts.name !== changedValue) return;
+ let attribute = this.getAttribute(opts.name);
+ if ((attribute === undefined || attribute === null) && opts.default)
+ attribute = opts.default;
+ if (opts.required && (attribute === undefined || attributes === null))
+ throw new Error(
+ `Attribute ${opts.name} should be set in component ${this.tagName}`
+ );
+ if (!opts.allowedValues.find((values) => values === attribute))
+ throw new Error(
+ `Attribute ${opts.name} can only be values: [${opts.allowedValues}]`
+ );
+ this[field] = attribute;
+ });
+ }
+
+ connectedCallback() {
+ this.validateAttributes();
+ }
+
+ attributeChangedCallback(attr) {
+ this.validateAttributes();
+
+ if (attr === attributes.type.name) {
+ this.button.classList.toggle("btn-primary", this.type === "primary");
+ this.button.classList.toggle("btn-secondary", this.type === "secondary");
+ }
+ }
+}
+
+customElements.define("styled-button", StyledButton);
diff --git a/manifest-generator/design-reference.html b/manifest-generator/design-reference.html
new file mode 100644
index 0000000..7834a6a
--- /dev/null
+++ b/manifest-generator/design-reference.html
@@ -0,0 +1,70 @@
+
+
+
+
+ Manifest Generator
+
+
+
+
+
+
+
+
+
+
+
+ Title Lorem ipsum
+ H1 Lorem ipsum
+ H2 Lorem ipsum
+ H3 Lorem ipsum
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam eu turpis
+ molestie, dictum est a, mattis tellus. Sed dignissim, metus nec
+ fringilla accumsan, risus sem sollicitudin lacus, ut interdum tellus
+ elit sed risus. Maecenas eget condimentum velit, sit amet feugiat
+ lectus. Class aptent taciti sociosqu ad litora torquent per conubia
+ nostra, per inceptos himenaeos.
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam eu turpis
+ molestie, dictum est a, mattis tellus.
+
+
+ Error: Lorem ipsum dolor sit
+
+ Primary
+
+
+ Secondary
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/manifest-generator/icons/48x48.png b/manifest-generator/icons/48x48.png
new file mode 100644
index 0000000..b549bc4
Binary files /dev/null and b/manifest-generator/icons/48x48.png differ
diff --git a/manifest-generator/icons/512x512.png b/manifest-generator/icons/512x512.png
new file mode 100644
index 0000000..cb88de1
Binary files /dev/null and b/manifest-generator/icons/512x512.png differ
diff --git a/manifest-generator/index.html b/manifest-generator/index.html
new file mode 100644
index 0000000..762d118
--- /dev/null
+++ b/manifest-generator/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+ Manifest Generator
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/manifest-generator/manifest.json b/manifest-generator/manifest.json
new file mode 100644
index 0000000..393628d
--- /dev/null
+++ b/manifest-generator/manifest.json
@@ -0,0 +1,17 @@
+{
+ "name": "Manifest Generator",
+ "start_url": "./index.html",
+ "display": "standalone",
+ "icons": [
+ {
+ "src": "icons/48x48.png",
+ "type": "image/png",
+ "sizes": "48x48"
+ },
+ {
+ "src": "icons/512x512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ]
+}
diff --git a/manifest-generator/state.js b/manifest-generator/state.js
new file mode 100644
index 0000000..5fa4f84
--- /dev/null
+++ b/manifest-generator/state.js
@@ -0,0 +1,28 @@
+let manifestState = {};
+
+// Read (Get) entire object from LocalStorage.
+// Other places can use this as:
+// const state = getManifest();
+// state.name ...
+export const getManifest = () => {
+ return structuredClone(manifestState);
+};
+
+// Write (Set) entire object LocalStorage.
+// Other places can use this as:
+// let state = getManifest();
+// state.name = "new value";
+// setManifest(state);
+export const setManifest = (newState) => {
+ if (newState == manifestState) {
+ return;
+ }
+
+ manifestState = newState;
+ localStorage.setItem("manifest", JSON.stringify(newState));
+};
+
+export const readManifestFromLocalStorage = () => {
+ const manifestString = localStorage.getItem("manifest");
+ manifestState = JSON.parse(manifestString);
+};
diff --git a/manifest-generator/style.css b/manifest-generator/style.css
new file mode 100644
index 0000000..9ae8be9
--- /dev/null
+++ b/manifest-generator/style.css
@@ -0,0 +1,7 @@
+@import url("./styles/defaults.css");
+@import url("./styles/button.css");
+@import url("./styles/input.css");
+
+body {
+ background: var(--c-bkg);
+}
diff --git a/manifest-generator/styles/button.css b/manifest-generator/styles/button.css
new file mode 100644
index 0000000..48bfa31
--- /dev/null
+++ b/manifest-generator/styles/button.css
@@ -0,0 +1,77 @@
+button {
+ appearance: button;
+ -webkit-appearance: button;
+
+ background-color: initial;
+ background-image: none;
+
+ font-family: inherit;
+ font-size: 16px;
+
+ text-transform: uppercase;
+
+ color: var(--c-text);
+
+ cursor: pointer;
+ margin: 0 0.2em;
+}
+
+a.btn-primary,
+a.btn-secondary,
+a.btn-primary:focus-visible,
+a.btn-secondary:focus-visible {
+ border: none;
+ color: var(--c-white);
+ text-transform: uppercase;
+ font-size: 16px;
+ line-height: 3em;
+ margin: 0 0.2em;
+}
+
+button.btn-primary,
+a.btn-primary {
+ background-color: var(--c-blue);
+ font-weight: 500;
+ padding: 0.8em 1.5em;
+ border-radius: var(--border-radius);
+
+ transition: var(--transition-color);
+}
+
+button.btn-primary:hover,
+button.btn-primary:focus,
+a.btn-primary:hover,
+a.btn-primary:focus {
+ background-color: var(--c-blue-dark);
+}
+
+button.btn-primary:focus-visible,
+a.btn-primary:focus-visible {
+ outline: 2px solid var(--c-blue-dark);
+ outline-offset: 2px;
+ background-color: var(--c-blue-dark);
+}
+
+button.btn-secondary,
+a.btn-secondary {
+ background-color: var(--c-gray);
+ font-weight: 500;
+ padding: 0.8em 1.5em;
+ border-radius: var(--border-radius);
+
+ transition: var(--transition-color);
+}
+
+button.btn-secondary:hover,
+button.btn-secondary:focus,
+a.btn-secondary:hover,
+a.btn-secondary:focus {
+ background-color: var(--c-gray-light);
+}
+
+button.btn-secondary:focus-visible,
+a.btn-secondary:focus-visible {
+ outline: 2px solid var(--c-gray-light);
+ outline-offset: 2px;
+ background-color: var(--c-gray-light);
+}
diff --git a/manifest-generator/styles/defaults.css b/manifest-generator/styles/defaults.css
new file mode 100644
index 0000000..65b5517
--- /dev/null
+++ b/manifest-generator/styles/defaults.css
@@ -0,0 +1,140 @@
+:root {
+ --c-bkg: #252931;
+ --c-bkg-secondary: #353942;
+ --c-white: #ffffff;
+ --c-blue: #528bff;
+ --c-gray: #555b68;
+ --c-gray-light: #898b90;
+ --c-blue-gray: #898b90;
+ --c-blue-dark: #2870ff;
+ --c-red: #cb463a;
+ --c-bkg-card: var(--c-bkg-secondary);
+ --c-text: var(--c-white);
+ --c-error: var(--c-red);
+
+ --border-radius: 10px;
+
+ --transition-duration: 150ms;
+ --transition-color: color var(--transition-duration) ease-in-out,
+ background-color var(--transition-duration) ease-in-out;
+}
+
+*,
+:after,
+:before {
+ box-sizing: border-box;
+ border: 0 solid #e5e7eb;
+}
+
+html {
+ line-height: 1.5;
+ -webkit-text-size-adjust: 100%;
+ tab-size: 4;
+ font-family: "Montserrat", ui-sans-serif, system-ui, -apple-system,
+ BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans,
+ sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol,
+ Noto Color Emoji;
+}
+
+body {
+ margin: 0;
+ line-height: inherit;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+h1,
+h2,
+h3,
+p,
+span,
+a {
+ color: var(--c-text);
+}
+
+h1.text-title {
+ font-size: 72px;
+ line-height: 110%;
+ font-weight: 900;
+}
+
+h1 {
+ font-size: 48px;
+ line-height: 110%;
+ letter-spacing: -1%;
+ font-weight: 700;
+}
+
+h2 {
+ font-size: 32px;
+ line-height: 110%;
+ letter-spacing: 0%;
+ font-weight: 700;
+}
+
+h3 {
+ font-size: 24px;
+ line-height: 150%;
+ letter-spacing: 0%;
+ font-weight: 500;
+}
+
+p,
+span,
+a {
+ font-size: 16px;
+ line-height: 150%;
+ letter-spacing: 2%;
+ font-weight: 400;
+}
+
+.text-sub {
+ font-size: 14px;
+ line-height: 150%;
+ letter-spacing: 2%;
+ font-weight: 400;
+}
+
+.text-error {
+ color: var(--c-error);
+}
+
+a {
+ font-size: 16px;
+ line-height: 150%;
+ font-weight: 500;
+ border-bottom: 2px solid currentColor;
+}
+
+a:hover,
+a:focus {
+ color: var(--c-blue);
+ transition: var(--transition-color);
+}
+
+a:focus-within {
+ outline: none;
+}
+
+p a {
+ font-size: inherit;
+ font-weight: inherit;
+ color: var(--c-blue);
+}
+
+p a:hover,
+p a:focus {
+ color: var(--c-blue-dark);
+}
diff --git a/manifest-generator/styles/input.css b/manifest-generator/styles/input.css
new file mode 100644
index 0000000..43d1d65
--- /dev/null
+++ b/manifest-generator/styles/input.css
@@ -0,0 +1,45 @@
+input[type="text"] {
+ background-color: var(--c-gray);
+ padding: 0.8em 1em;
+ border-radius: var(--border-radius);
+ color: var(--c-text);
+ font-size: 16px;
+ font-weight: 400;
+ font-family: inherit;
+ margin: 0 0.2em;
+}
+
+::placeholder {
+ color: var(--c-blue-gray);
+ opacity: 1; /* Firefox */
+}
+:-ms-input-placeholder {
+ /* Internet Explorer 10-11 */
+ color: var(--c-blue-gray);
+}
+::-ms-input-placeholder {
+ /* Microsoft Edge */
+ color: var(--c-blue-gray);
+}
+
+textarea {
+ background-color: var(--c-gray);
+ padding: 0.8em 1em;
+ border-radius: var(--border-radius);
+ color: var(--c-text);
+ font-size: 16px;
+ font-weight: 400;
+ font-family: inherit;
+ margin: 0 0.2em;
+ height: 10em;
+ min-width: 400px;
+ resize: vertical;
+}
+
+input[error],
+textarea[error] {
+ outline-style: solid;
+ outline-color: var(--c-red);
+ outline-width: 2px;
+ outline-offset: 2px;
+}
diff --git a/manifest-generator/sw.js b/manifest-generator/sw.js
new file mode 100644
index 0000000..0da6ac1
--- /dev/null
+++ b/manifest-generator/sw.js
@@ -0,0 +1,8 @@
+self.addEventListener("fetch", (e) => {
+ e.respondWith(
+ fetch(e.request).catch(() => {
+ // To-do: useful offline experience.
+ return new Response("Hello offline page");
+ })
+ );
+});