From b9301879e7be5e22a40bbce38d4eec82193d93eb Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Mon, 21 Sep 2026 19:42:31 -0700 Subject: [PATCH] Initial SOTF inventory editor for GitHub Pages --- README.md | 41 +++++ app.js | 486 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 102 +++++++++++ items.js | 382 +++++++++++++++++++++++++++++++++++++++++ styles.css | 434 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1445 insertions(+) create mode 100644 README.md create mode 100644 app.js create mode 100644 index.html create mode 100644 items.js create mode 100644 styles.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..3876cf5 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# sotf-inventory + +A browser based inventory editor for Sons of the Forest. Runs entirely on your computer, nothing is uploaded. + +Live page: https://justinoros.github.io/sotf-inventory/ + +## How to use + +1. Close Sons of the Forest. +2. Open your save folder: `%USERPROFILE%\AppData\LocalLow\Endnight\SonsOfTheForest\Saves\\SinglePlayer\\` +3. Copy `SaveData.zip` somewhere safe as a backup. +4. Drag `SaveData.zip` onto the page, or click Browse. +5. Search for items and set their counts. Counts are limited from 0 to the in game max for each item. +6. Click Save to download the new `SaveData.zip`. +7. Replace the old `SaveData.zip` in your save folder with the new one and start the game. + +A loose `PlayerInventorySaveData.json` also works if you prefer to edit the extracted file. + +## Notes + +* Unequip items in game before you save and quit. Equipped items are shown but locked. +* Items the editor doesn't recognize are marked unknown and can only be lowered, not raised. +* Blueprints also need entries in `PlayerStateSaveData.json` to show as unlocked. This tool only edits inventory. +* Browsers can't open a file dialog at a specific folder. Use Copy path and paste it into the dialog's address bar. Chrome and Edge remember the last folder you picked. + +## Hosting on GitHub Pages + +Push the repo, then go to Settings, Pages, and set the source to the `main` branch root. + +## Files + +* `index.html` page layout +* `styles.css` theme +* `app.js` load, edit, and save logic +* `items.js` item IDs, names, categories, and max counts + +## Credits + +Item IDs, names, and inventory limits come from [SOTFEdit](https://github.com/codengine/SOTFEdit) by codengine, MIT License. + +Not affiliated with Endnight Games. diff --git a/app.js b/app.js new file mode 100644 index 0000000..a1e6931 --- /dev/null +++ b/app.js @@ -0,0 +1,486 @@ +(() => { + const DISCLAIMER_KEY = "sotf-inventory-disclaimer-v1"; + const SAVES_PATH = "%USERPROFILE%\\AppData\\LocalLow\\Endnight\\SonsOfTheForest\\Saves"; + const INVENTORY_FILE = "PlayerInventorySaveData.json"; + + const $ = (id) => document.getElementById(id); + const els = { + dropZone: $("dropZone"), + loaderTitle: $("loaderTitle"), + loaderStatus: $("loaderStatus"), + browseBtn: $("browseBtn"), + fileInput: $("fileInput"), + savesPath: $("savesPath"), + copyPathBtn: $("copyPathBtn"), + search: $("search"), + category: $("category"), + ownedOnly: $("ownedOnly"), + resultCount: $("resultCount"), + rows: $("rows"), + table: $("inventory"), + emptyState: $("emptyState"), + changeSummary: $("changeSummary"), + resetBtn: $("resetBtn"), + saveBtn: $("saveBtn"), + disclaimer: $("disclaimer"), + disclaimerOk: $("disclaimerOk"), + saveDialog: $("saveDialog"), + saveFileName: $("saveFileName"), + saveCancel: $("saveCancel"), + saveConfirm: $("saveConfirm"), + toast: $("toast"), + toolbar: document.querySelector(".toolbar") + }; + + const state = { + items: new Map(), + sortKey: "count", + sortDir: -1, + query: "", + category: "", + ownedOnly: false, + source: null + }; + + function buildCatalog() { + state.items.clear(); + for (const [id, name, category, max] of SOTF_ITEMS) { + state.items.set(id, { id, name, category, max, count: 0, original: 0, equipped: false, known: true }); + } + } + + function fillCategories() { + const cats = [...new Set(SOTF_ITEMS.map((i) => i[2]))].sort(); + for (const c of cats) { + const opt = document.createElement("option"); + opt.value = c; + opt.textContent = c; + els.category.appendChild(opt); + } + } + + function ensureUnknownCategory() { + const hasUnknown = [...state.items.values()].some((i) => !i.known); + const existing = els.category.querySelector('option[value="Unknown"]'); + if (hasUnknown && !existing) { + const opt = document.createElement("option"); + opt.value = "Unknown"; + opt.textContent = "Unknown"; + els.category.appendChild(opt); + } else if (!hasUnknown && existing) { + existing.remove(); + } + } + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + } + + function clamp(value, max) { + const n = Math.floor(Number(value)); + if (!Number.isFinite(n) || n < 0) return 0; + return Math.min(n, max); + } + + function visibleItems() { + const q = state.query.trim().toLowerCase(); + const qNum = /^\d+$/.test(q) ? Number(q) : null; + const list = [...state.items.values()].filter((i) => { + if (state.category && i.category !== state.category) return false; + if (state.ownedOnly && i.count === 0 && !i.equipped) return false; + if (!q) return true; + return i.name.toLowerCase().includes(q) || (qNum !== null && i.id === qNum); + }); + const dir = state.sortDir; + const key = state.sortKey; + list.sort((a, b) => { + let r; + if (key === "name" || key === "category") r = a[key].localeCompare(b[key]); + else r = a[key] - b[key]; + if (r === 0) r = key === "name" ? a.id - b.id : a.name.localeCompare(b.name); + else r *= dir; + return r; + }); + return list; + } + + function rowHtml(i) { + const locked = i.equipped; + const pills = []; + if (i.equipped) pills.push('equipped'); + if (!i.known) pills.push('unknown'); + return ` + ${escapeHtml(i.name)}${pills.join("")} + ${escapeHtml(i.category)} + ${i.id} + +
+ + + +
+ + ${i.max.toLocaleString()} + `; + } + + function rowClass(i) { + const c = []; + if (i.count !== i.original) c.push("changed"); + if (i.count === 0 && !i.equipped) c.push("zero"); + return c.join(" "); + } + + function render() { + const list = visibleItems(); + els.rows.innerHTML = list.map(rowHtml).join(""); + els.emptyState.hidden = list.length > 0; + els.resultCount.textContent = `${list.length} of ${state.items.size} items`; + for (const th of els.table.querySelectorAll("th[data-sort]")) { + if (th.dataset.sort === state.sortKey) th.setAttribute("aria-sort", state.sortDir === 1 ? "ascending" : "descending"); + else th.removeAttribute("aria-sort"); + } + updateSummary(); + } + + function updateRow(i) { + const tr = els.rows.querySelector(`tr[data-id="${i.id}"]`); + if (!tr) return; + tr.className = rowClass(i); + const input = tr.querySelector("input"); + if (document.activeElement !== input || Number(input.value) !== i.count) input.value = i.count; + tr.querySelector('[data-step="-1"]').disabled = i.equipped || i.count <= 0; + tr.querySelector('[data-step="1"]').disabled = i.equipped || i.count >= i.max; + tr.querySelector("[data-fill]").disabled = i.equipped || i.count >= i.max; + } + + function changedItems() { + return [...state.items.values()].filter((i) => i.count !== i.original); + } + + function updateSummary() { + const n = changedItems().length; + els.changeSummary.textContent = n === 0 ? "No changes" : `${n} item${n === 1 ? "" : "s"} changed`; + els.changeSummary.classList.toggle("has-changes", n > 0); + els.resetBtn.disabled = n === 0; + els.saveBtn.disabled = !state.source || n === 0; + els.saveBtn.title = state.source ? "" : "Load a save first"; + } + + function setCount(id, value) { + const i = state.items.get(id); + if (!i || i.equipped) return; + i.count = clamp(value, i.max); + updateRow(i); + updateSummary(); + } + + let toastTimer; + function toast(msg, isError) { + els.toast.textContent = msg; + els.toast.classList.toggle("error", !!isError); + els.toast.classList.add("show"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => els.toast.classList.remove("show"), isError ? 7000 : 4500); + } + + function parseInventory(text) { + let outer; + try { + outer = JSON.parse(text); + } catch { + throw new Error(`${INVENTORY_FILE} isn't valid JSON. The save may be damaged.`); + } + const raw = outer && outer.Data && outer.Data.PlayerInventory; + if (raw === undefined) throw new Error(`${INVENTORY_FILE} has no PlayerInventory data. This doesn't look like a Sons of the Forest save.`); + const innerIsString = typeof raw === "string"; + const inner = innerIsString ? JSON.parse(raw) : raw; + const blocks = inner && inner.ItemInstanceManagerData && inner.ItemInstanceManagerData.ItemBlocks; + if (!Array.isArray(blocks)) throw new Error("The inventory has no item list. This save format isn't supported."); + return { outer, inner, innerIsString }; + } + + function applyInventory(inner) { + buildCatalog(); + const addUnknown = (id, count) => { + const name = SOTF_NAMES[id] || `Unknown item ${id}`; + const item = { id, name, category: "Unknown", max: Math.max(count, 1), count: 0, original: 0, equipped: false, known: false }; + state.items.set(id, item); + return item; + }; + for (const block of inner.ItemInstanceManagerData.ItemBlocks) { + const id = Number(block.ItemId); + const count = Math.max(0, Number(block.TotalCount) || 0); + const item = state.items.get(id) || addUnknown(id, count); + item.count += count; + if (item.count > item.max) item.max = item.count; + } + if (Array.isArray(inner.EquippedItems)) { + for (const block of inner.EquippedItems) { + const id = Number(block.ItemId); + const item = state.items.get(id) || addUnknown(id, 0); + item.equipped = true; + } + } + for (const item of state.items.values()) item.original = item.count; + ensureUnknownCategory(); + } + + async function loadFile(file) { + if (!file) return; + try { + const isJson = /\.json$/i.test(file.name); + let zip = null; + let path = INVENTORY_FILE; + let text; + if (isJson) { + text = await file.text(); + } else { + zip = await JSZip.loadAsync(file); + const entry = Object.values(zip.files).find((f) => !f.dir && f.name.split("/").pop() === INVENTORY_FILE); + if (!entry) throw new Error(`${file.name} has no ${INVENTORY_FILE}. Pick the SaveData.zip inside one of your save folders.`); + path = entry.name; + text = await entry.async("string"); + } + const parsed = parseInventory(text); + applyInventory(parsed.inner); + state.source = { kind: isJson ? "json" : "zip", zip, path, fileName: isJson ? INVENTORY_FILE : "SaveData.zip", ...parsed }; + state.sortKey = "count"; + state.sortDir = -1; + const owned = [...state.items.values()].filter((i) => i.count > 0).length; + els.dropZone.classList.add("loaded"); + els.loaderTitle.textContent = `Loaded ${file.name}`; + els.loaderStatus.textContent = `${owned} item types in your inventory. Drop another file to switch saves.`; + render(); + toast(`Loaded ${file.name}`); + } catch (err) { + console.error(err); + toast(err && err.message ? err.message : `Couldn't read ${file.name}.`, true); + } + } + + function buildInventoryText() { + const { outer, inner, innerIsString } = state.source; + const blocks = inner.ItemInstanceManagerData.ItemBlocks; + const removed = new Set(); + for (const item of changedItems()) { + if (item.equipped) continue; + const matches = blocks.filter((b) => Number(b.ItemId) === item.id); + if (item.count === 0) { + removed.add(item.id); + for (const b of matches) blocks.splice(blocks.indexOf(b), 1); + continue; + } + if (matches.length === 0) { + blocks.push({ ItemId: item.id, TotalCount: item.count, UniqueItems: [] }); + continue; + } + const [first, ...rest] = matches; + first.TotalCount = item.count; + if (Array.isArray(first.UniqueItems) && first.UniqueItems.length > item.count) { + first.UniqueItems = first.UniqueItems.slice(0, item.count); + } + for (const b of rest) blocks.splice(blocks.indexOf(b), 1); + } + const slots = inner.QuickSelect && inner.QuickSelect.Slots; + if (Array.isArray(slots) && removed.size) { + inner.QuickSelect.Slots = slots.filter((s) => !(s && removed.has(Number(s.ItemId)))); + } + outer.Data.PlayerInventory = innerIsString ? JSON.stringify(inner) : inner; + return JSON.stringify(outer); + } + + function download(blob, name) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10000); + } + + async function doSave() { + try { + const text = buildInventoryText(); + const src = state.source; + let blob; + if (src.kind === "zip") { + src.zip.file(src.path, text); + blob = await src.zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } }); + } else { + blob = new Blob([text], { type: "application/json" }); + } + download(blob, src.fileName); + for (const item of state.items.values()) item.original = item.count; + render(); + toast(`Downloaded ${src.fileName}. Replace the file in your save folder, then start the game.`); + } catch (err) { + console.error(err); + toast(`Couldn't build the new save: ${err.message}`, true); + } + } + + async function browse() { + if (window.showOpenFilePicker) { + try { + const [handle] = await window.showOpenFilePicker({ + id: "sotf-saves", + startIn: "documents", + types: [{ description: "Sons of the Forest save", accept: { "application/zip": [".zip"], "application/json": [".json"] } }] + }); + loadFile(await handle.getFile()); + return; + } catch (err) { + if (err && err.name === "AbortError") return; + } + } + els.fileInput.value = ""; + els.fileInput.click(); + } + + function setToolbarOffset() { + document.documentElement.style.setProperty("--toolbar-h", `${els.toolbar.offsetHeight}px`); + } + + function bind() { + els.savesPath.textContent = SAVES_PATH; + els.copyPathBtn.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(SAVES_PATH); + toast("Path copied. Paste it into the file dialog's address bar."); + } catch { + toast("Couldn't copy. Select the path and copy it manually.", true); + } + }); + + els.browseBtn.addEventListener("click", browse); + els.fileInput.addEventListener("change", () => loadFile(els.fileInput.files[0])); + + let dragDepth = 0; + const hasFiles = (e) => e.dataTransfer && [...e.dataTransfer.types].includes("Files"); + window.addEventListener("dragover", (e) => { if (hasFiles(e)) e.preventDefault(); }); + window.addEventListener("drop", (e) => { if (hasFiles(e)) e.preventDefault(); }); + els.dropZone.addEventListener("dragenter", (e) => { + if (!hasFiles(e)) return; + dragDepth++; + els.dropZone.classList.add("dragging"); + }); + els.dropZone.addEventListener("dragleave", () => { + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) els.dropZone.classList.remove("dragging"); + }); + els.dropZone.addEventListener("drop", (e) => { + e.preventDefault(); + dragDepth = 0; + els.dropZone.classList.remove("dragging"); + loadFile(e.dataTransfer.files[0]); + }); + els.dropZone.addEventListener("keydown", (e) => { + if (e.target === els.dropZone && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + browse(); + } + }); + + let searchTimer; + els.search.addEventListener("input", () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { state.query = els.search.value; render(); }, 80); + }); + els.category.addEventListener("change", () => { state.category = els.category.value; render(); }); + els.ownedOnly.addEventListener("change", () => { state.ownedOnly = els.ownedOnly.checked; render(); }); + + els.table.querySelector("thead").addEventListener("click", (e) => { + const th = e.target.closest("th[data-sort]"); + if (!th) return; + const key = th.dataset.sort; + if (state.sortKey === key) state.sortDir *= -1; + else { + state.sortKey = key; + state.sortDir = key === "name" || key === "category" ? 1 : -1; + } + render(); + }); + + els.rows.addEventListener("click", (e) => { + const tr = e.target.closest("tr[data-id]"); + if (!tr) return; + const id = Number(tr.dataset.id); + const item = state.items.get(id); + const step = e.target.closest("[data-step]"); + if (step) { + const amount = Number(step.dataset.step) * (e.shiftKey ? 10 : 1); + setCount(id, item.count + amount); + } else if (e.target.closest("[data-fill]")) { + setCount(id, item.max); + } + }); + + els.rows.addEventListener("input", (e) => { + if (e.target.tagName !== "INPUT") return; + const id = Number(e.target.closest("tr").dataset.id); + const item = state.items.get(id); + if (e.target.value === "") return; + const v = clamp(e.target.value, item.max); + if (String(v) !== e.target.value) e.target.value = v; + setCount(id, v); + }); + + els.rows.addEventListener("focusout", (e) => { + if (e.target.tagName !== "INPUT") return; + const id = Number(e.target.closest("tr").dataset.id); + const item = state.items.get(id); + if (item) e.target.value = item.count; + }); + + els.rows.addEventListener("keydown", (e) => { + if (e.target.tagName === "INPUT" && e.key === "Enter") e.target.blur(); + }); + + els.resetBtn.addEventListener("click", () => { + for (const item of state.items.values()) item.count = item.original; + render(); + }); + + els.saveBtn.addEventListener("click", () => { + if (!state.source) return; + els.saveFileName.textContent = state.source.fileName; + els.saveDialog.showModal(); + }); + els.saveCancel.addEventListener("click", () => els.saveDialog.close()); + els.saveConfirm.addEventListener("click", () => { + els.saveDialog.close(); + doSave(); + }); + + els.disclaimerOk.addEventListener("click", () => { + try { localStorage.setItem(DISCLAIMER_KEY, "1"); } catch {} + els.disclaimer.close(); + }); + els.disclaimer.addEventListener("cancel", (e) => e.preventDefault()); + + window.addEventListener("beforeunload", (e) => { + if (changedItems().length) { + e.preventDefault(); + e.returnValue = ""; + } + }); + + window.addEventListener("resize", setToolbarOffset); + } + + function showDisclaimerIfNeeded() { + let seen = false; + try { seen = localStorage.getItem(DISCLAIMER_KEY) === "1"; } catch {} + if (!seen) els.disclaimer.showModal(); + } + + buildCatalog(); + fillCategories(); + bind(); + render(); + setToolbarOffset(); + showDisclaimerIfNeeded(); +})(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..61b9232 --- /dev/null +++ b/index.html @@ -0,0 +1,102 @@ + + + + + +SOTF Inventory Editor + + + + + + + +
+
+

Inventory

+

A save editor for Sons of the Forest. Load your save, set your counts, download the new file.

+
+
+ +
+
+
+

Drop your SaveData.zip here

+

No save loaded. You can browse and edit items, but saving needs a loaded save.

+
+
+ + +
+
+ Saves folder + + +
+
+ +
+ + + + +
+ +
+ + + + + + + + + + + +
+ +
+
+ +
+ No changes +
+ + +
+
+ + +

Close the game first

+

Quit Sons of the Forest before you edit or replace a save. The game rewrites its save files while running and can overwrite your changes or corrupt the save.

+

Unequip items in game before you save and quit. Equipped items are shown here but can't be edited.

+

This is a fan tool with no connection to Endnight Games. Your save never leaves your computer.

+
+ +
+
+ + +

Download the new save

+

You'll get a new SaveData.zip with your changes.

+

Keep your original file as a backup. Copy it somewhere safe before you replace it, in case the modified save doesn't load.

+

Then put the new file in your save folder, replacing the old one, and start the game.

+
+ + +
+
+ +
+ + + + + + diff --git a/items.js b/items.js new file mode 100644 index 0000000..6629927 --- /dev/null +++ b/items.js @@ -0,0 +1,382 @@ +const SOTF_ITEMS = [ + [78, "Log", "Log", 2], + [340, "Guitar", "Weapon", 1], + [341, "Binoculars", "Equipment", 1], + [346, "Shotgun Rail", "Weapon Modification", 1], + [353, "Stun Gun", "Weapon", 1], + [354, "Night Vision Goggles", "Equipment", 1], + [355, "Compact Pistol", "Weapon", 1], + [356, "Modern Axe", "Weapon", 1], + [358, "Shotgun", "Weapon", 1], + [359, "Machete", "Weapon", 1], + [360, "Compound/Tactical Bow", "Weapon", 1], + [361, "Rifle", "Weapon", 1], + [362, "Pistol Ammo", "Ammo", 1000], + [363, "Shotgun Slug", "Ammo", 1000], + [364, "Shotgun Buckshot", "Ammo", 1000], + [365, "Crossbow", "Weapon", 1], + [367, "Katana", "Weapon", 1], + [368, "Crossbow Bolt", "Ammo", 20], + [369, "Stun Gun Ammo", "Ammo", 1000], + [373, "Carbon Fiber Arrow", "Ammo", 20], + [374, "Pistol Suppressor/Silencer", "Weapon Modification", 1], + [375, "Laser Sight", "Weapon Modification", 1], + [376, "Pistol Rail", "Weapon Modification", 1], + [378, "Flashlight Attachment", "Attachment", 1], + [379, "Tactical Axe", "Weapon", 1], + [380, "Utility Knife", "Weapon", 1], + [381, "Grenade", "Weapon", 5], + [382, "GrenadeAmmo", "Ammo", 10], + [383, "Rifle Rail Mod", "Weapon Modification", 1], + [386, "Revolver", "Weapon", 1], + [387, "Rifle Ammo", "Ammo", 1000], + [388, "Molotov", "Weapon", 6], + [390, "Printer Resin", "Printed", 1000], + [391, "Red Mask", "Equipment", 1], + [392, "Stick", "Misc", 20], + [393, "Rock", "Misc", 10], + [394, "Chainsaw", "Weapon", 1], + [396, "Stun Baton", "Weapon", 1], + [397, "Shiitake", "Plant", 20], + [398, "King Oyster", "Food", 20], + [399, "Hydnum Repandum", "Plant", 20], + [400, "Fly Amanita", "Misc", 20], + [401, "Turtle Egg", "Misc", 4], + [402, "Backpack", "Container", 1], + [403, "Rope", "Misc", 10], + [405, "Bone", "Body Part", 16], + [410, "Watch", "Misc", 10], + [412, "GPS Tracker", "Misc", 1], + [413, "Plasma Lighter", "Printed", 1], + [414, "Vodka Bottle", "Misc", 6], + [415, "Cloth", "Misc", 1000], + [416, "Circuit Board", "Misc", 6], + [417, "Time Bomb", "Weapon", 3], + [418, "Wire", "Misc", 100], + [419, "Duct Tape", "Misc", 100], + [420, "C4 Brick", "Misc", 3], + [421, "Ramen Noodles", "Food", 4], + [422, "Repair Tool", "Tool", 1], + [423, "Novel [Pennant Line]", "Book", 1], + [424, "Novel [Deep Sleep]", "Book", 1], + [425, "Crunchie Wunchies", "Food", 4], + [426, "Printed Flask", "Printed", 1], + [428, "Printed Sled", "Printed", 1], + [430, "Skull", "Body Part", 3], + [431, "Firefighter Axe", "Weapon", 1], + [432, "Can Opener", "Tool", 1], + [433, "Meat", "Food", 7], + [434, "Canned Food", "Food", 6], + [435, "Golden Mask", "Equipment", 1], + [436, "Fish", "Food", 3], + [437, "Meds", "Consumable", 6], + [438, "MRE Pack", "Food", 6], + [439, "Energy Drink", "Food", 10], + [440, "Flare", "Misc", 10], + [441, "Energy Bar", "Food", 10], + [443, "Crafted Bow", "Weapon", 1], + [444, "Rebreather", "Equipment", 1], + [445, "Blueberries", "Food", 20], + [446, "Twinberries", "Food", 20], + [447, "Salmonberries", "Food", 20], + [448, "Snowberries", "Food", 20], + [449, "Devil's Club", "Plant", 20], + [450, "Horsetail", "Plant", 20], + [451, "Aloe Vera", "Plant", 20], + [452, "Yarrow", "Plant", 20], + [453, "Fireweed", "Plant", 20], + [454, "Arrowleaf", "Plant", 20], + [455, "Health Mix", "Consumable", 3], + [456, "Health Mix+", "Consumable", 3], + [457, "Stun Gun Ammo", "Ammo", 1], + [459, "Slingshot", "Weapon", 1], + [461, "Energy Mix", "Consumable", 3], + [462, "Energy Mix+", "Consumable", 3], + [464, "Cat Food", "Food", 1], + [465, "Chicory", "Plant", 20], + [466, "Oyster", "Food", 5], + [467, "Album Cover", "Document", 1], + [468, "Cross", "Weapon", 1], + [469, "Air Canister", "Misc", 4], + [471, "Flashlight", "Equipment", 1], + [472, "Deer Hide", "Misc", 5], + [473, "Leaf Armour", "Armor", 10], + [474, "Crafted Spear", "Weapon", 5], + [476, "Small Rock", "Misc", 30], + [477, "Crafted Club", "Weapon", 1], + [479, "Feather", "Misc", 1000], + [480, "Severed Arm", "Body Part", 2], + [481, "Severed Leg", "Body Part", 2], + [482, "Severed Head", "Body Part", 1], + [483, "Emergency Pack", "Food", 1], + [484, "Leaf", "Misc", 1000], + [485, "Shovel", "Tool", 1], + [486, "Walkie Talkie", "Equipment", 1], + [487, "Silk Pajamas", "Clothes", 1], + [489, "Tactical Pants", "Clothes", 1], + [490, "Hoodie", "Clothes", 1], + [491, "Blazer", "Clothes", 1], + [492, "Tuxedo", "Clothes", 1], + [493, "Leather Jacket", "Clothes", 1], + [494, "Bone Armour", "Armor", 10], + [495, "Tactical Jacket", "Clothes", 2], + [496, "Money", "Misc", 1000000], + [499, "Wetsuit", "Clothes", 1], + [500, "Winter Jacket", "Clothes", 1], + [501, "Tactical Boots", "Clothes", 1], + [502, "Coins", "Misc", 1000], + [503, "Torch", "Misc", 1], + [504, "Tarp", "Misc", 5], + [506, "Turtle Shell", "Misc", 3], + [507, "Stone Arrow", "Ammo", 20], + [508, "Skin Pouch", "Misc", 6], + [509, "Holosprings Pamphlet", "Document", 1], + [517, "Cooking Pot", "Tool", 1], + [518, "Paper Target", "Misc", 50], + [519, "Deer Hide Armour", "Armor", 10], + [521, "Email [More Lighting]", "Document", 1], + [522, "Rope Gun", "Equipment", 1], + [523, "Zipline Rope", "Misc", 4], + [524, "Golf Ball", "Misc", 8], + [525, "Putter", "Weapon", 1], + [526, "Mr Puffy Will", "Document", 1], + [527, "Batteries", "Electricity", 6], + [528, "MrPuffy Financials", "Document", 1], + [529, "GPS Locator", "Misc", 10], + [530, "Drying Rack Blueprint", "Blueprint", 1], + [531, "Stick Chair Blueprint", "Blueprint", 1], + [532, "Stick Bed Blueprint", "Blueprint", 1], + [533, "Weapon Rack Blueprint", "Blueprint", 1], + [534, "Golf Email Pt 1", "Document", 1], + [535, "Golf Email Pt 2", "Document", 1], + [536, "Golf Email Pt 3", "Document", 1], + [537, "Golf Email Pt 4", "Document", 1], + [538, "Painting Email", "Document", 1], + [539, "Document [Out Demonia]", "Document", 1], + [540, "Document [Crosses burn Demons]", "Document", 1], + [541, "Document [Cube Activation Cycles]", "Document", 1], + [542, "Email [Thought you might find this interesting]", "Document", 1], + [543, "Email [Another one]", "Document", 1], + [544, "Email [One more]", "Document", 1], + [545, "Email [Photo Reference]", "Document", 1], + [546, "Email [Note to all employees]", "Document", 1], + [547, "Email [Safe in Cube]", "Document", 1], + [548, "Document [Stay in the Cube]", "Document", 1], + [549, "Bone Maker Blueprint", "Blueprint", 1], + [550, "Fly Swatter Blueprint", "Blueprint", 1], + [551, "Small Animal Catcher Blueprint", "Blueprint", 1], + [552, "S.C.R.E.W.", "Document", 1], + [553, "Tech Mesh", "Misc", 10], + [554, "Tech Armour", "Armor", 10], + [555, "Track Suit", "Clothes", 1], + [556, "Dress", "Clothes", 1], + [557, "Leather Suit", "Clothes", 1], + [558, "Camouflage Suit", "Clothes", 1], + [560, "Printed Grappling Hook", "Printed", 2], + [562, "HolovilleNoticeA", "Document", 1], + [563, "HolovilleNoticeB", "Document", 1], + [564, "HolovilleNoticeC", "Document", 1], + [565, "HolovilleNoticeD", "Document", 1], + [566, "Maintenance Key Card", "Key", 1], + [567, "Guest Key Card", "Key", 1], + [568, "VIP Key Card", "Key", 1], + [569, "Brain Bite", "Food", 1], + [570, "Steak Bite", "Food", 1], + [571, "Steak and Bacon Bite", "Food", 1], + [572, "Ancient Armor", "Armor", 1], + [574, "Novel [Rescued]", "Book", 1], + [575, "Novel [Parallel Universes]", "Book", 1], + [579, "Bone Chair Blueprint", "Blueprint", 1], + [580, "Stick Ladder Blueprint", "Blueprint", 1], + [581, "Bird House Blueprint", "Blueprint", 1], + [582, "Fish Trap Blueprint", "Blueprint", 1], + [584, "Mannequin Blueprint", "Blueprint", 1], + [585, "Scarecrow Blueprint", "Blueprint", 1], + [586, "Planter Wall Blueprint", "Blueprint", 1], + [587, "Planter Floor Blueprint", "Blueprint", 1], + [588, "Bone Chandelier Blueprint", "Blueprint", 1], + [589, "S.C.R.E.W. / Tutorials", "Document", 1], + [590, "Radio", "Misc", 1], + [591, "Small Log Cabin Blueprint", "Blueprint", 1], + [593, "Creepy Armour", "Armor", 10], + [594, "Guarana Berries", "Plant", 20], + [595, "Blackberries", "Food", 20], + [596, "AloeVera Seed", "Seed", 20], + [597, "Arrowleaf Seed", "Seed", 20], + [598, "Blackberry Seed", "Seed", 20], + [599, "Blueberry Seed", "Seed", 20], + [600, "DevilsClub Seed", "Seed", 20], + [601, "Fireweed Seed", "Seed", 20], + [602, "Guarana Seed", "Seed", 20], + [603, "Horsetail Seed", "Seed", 20], + [604, "Salmonberry Seed", "Seed", 20], + [605, "Chicory Seed", "Seed", 20], + [606, "Yarro Seed", "Seed", 20], + [607, "Rock Storage Blueprint", "Blueprint", 1], + [608, "Stone Storage Blueprint", "Blueprint", 1], + [609, "Log Storage Blueprint", "Blueprint", 1], + [610, "Sticks Storage Blueprint", "Blueprint", 1], + [611, "Numbers Printout", "Document", 1], + [612, "Newspaper Cutout [Missing CEO]", "Document", 1], + [613, "Newspaper Cutout [Puffcorp]", "Document", 1], + [614, "E-Mail [Artifact - Preliminary Findings]", "Document", 1], + [615, "E-Mail [Scientific Data - Cube]", "Document", 1], + [616, "Classified Document", "Document", 1], + [617, "Stock Price", "Document", 1], + [618, "Printed Arrow", "Ammo", 20], + [619, "Swim Suit", "Clothes", 1], + [620, "Table Blueprint", "Blueprint", 1], + [621, "Shelf Blueprint", "Blueprint", 1], + [622, "Rock Path Blueprint", "Blueprint", 1], + [623, "Wall Shelf Blueprint", "Blueprint", 1], + [624, "Wall Torch Blueprint", "Blueprint", 1], + [625, "Hanging Skull Light Blueprint", "Blueprint", 1], + [626, "Foldable Glider", "Misc", 1], + [627, "Mr. Puffton's Reservation Card", "Document", 1], + [628, "Mrs. Puffton's Reservation Card", "Document", 1], + [629, "Rain Catcher Blueprint", "Blueprint", 1], + [630, "KnightV", "Misc", 1], + [631, "The Realm Beyond", "Book", 1], + [632, "Spring Trap Blueprint", "Blueprint", 1], + [633, "Cult Pamphlet", "Document", 1], + [634, "Solar Panel", "Electricity", 3], + [635, "Light Bulb", "Electricity", 8], + [636, "TemplateD", "Misc", 30], + [637, "Action Camera", "Equipment", 1], + [638, "Basic Log Sled Blueprint", "Blueprint", 1], + [639, "Space Suit", "Clothes", 1], + [640, "Stone", "Misc", 1], + [641, "Stone Fireplace Blueprint", "Blueprint", 1], + [642, "Duck Head", "Trophy", 1], + [643, "Eagle Head", "Trophy", 1], + [644, "Small Turtle Head", "Trophy", 1], + [645, "Head Trophy Mount Blueprint", "Blueprint", 1], + [646, "Rabbit Head", "Trophy", 1], + [647, "Squirrel Head", "Trophy", 1], + [648, "Seagull Head", "Trophy", 1], + [649, "Stones Storage Blueprint", "Blueprint", 1], + [650, "Turtle Head", "Trophy", 1], + [651, "Deer Head", "Trophy", 1], + [652, "Moose Head", "Trophy", 1], + [653, "Personal Note A", "Document", 1], + [654, "Personal Note B", "Document", 1], + [655, "Personal Note C", "Document", 1], + [656, "Blueprint Hokey Pokey Trap", "Blueprint", 1], + [657, "Printed GPS Case", "Misc", 5], + [658, "Radio Trap Blueprint", "Blueprint", 1], + [659, "Radio Blueprint", "Blueprint", 1], + [661, "Golf Cart Battery", "Electricity", 3], + [662, "Artifact Piece A", "Misc", 1], + [663, "Pickaxe", "Misc", 1], + [664, "Solafite Ore", "Misc", 20], + [665, "Item Plating Blueprint", "Blueprint", 1], + [666, "Powered Cross Blueprint", "Blueprint", 1], + [667, "Artifact Piece B", "Misc", 1], + [668, "Artifact Piece C", "Misc", 1], + [669, "Artifact Piece D", "Misc", 1], + [670, "Gore Chair Blueprint", "Blueprint", 1], + [671, "Gore Couch Blueprint", "Blueprint", 1], + [672, "Uber Trap Blueprint", "Blueprint", 1], + [673, "Round Table Blueprint", "Blueprint", 1], + [674, "Old Note A", "Document", 1], + [675, "Old Note B", "Document", 1], + [676, "Old Note C", "Document", 1], + [677, "Fishing Email A", "Document", 1], + [678, "Fishing Email B", "Document", 1], + [679, "Grind Trap Blueprint", "Blueprint", 1], + [680, "Spotlight Blueprint", "Blueprint", 1], + [681, "Clock Blueprint", "Blueprint", 1], + [682, "Spin Trap Blueprint", "Blueprint", 1], + [683, "Double Bed Blueprint", "Blueprint", 1], + [684, "Old Note E", "Document", 1], + [685, "Old Note D", "Document", 1], + [686, "Cult Sign F", "Document", 1], + [687, "Puffton Email A", "Document", 1], + [688, "Spear Thrower Trap Blueprint", "Blueprint", 1], + [689, "Artifact Piece E", "Misc", 1], + [690, "Story Pages", "Document", 1], + [691, "Old Note F", "Document", 1], + [692, "Fishing Email C", "Document", 1], + [693, "Bunker Map A", "Document", 1], + [694, "Jianyu Email Printout A", "Document", 1], + [695, "Cult Sign G", "Document", 1], + [696, "Cult Sign H", "Document", 1], + [697, "Cult Sign I", "Document", 1], + [698, "Cult Sign J", "Document", 1], + [699, "Cult Sign K", "Document", 1], + [700, "Spear Storage Blueprint", "Blueprint", 1], + [701, "Leg Lamp Blueprint", "Blueprint", 1], + [702, "Firewood Storage Blueprint", "Blueprint", 1], + [703, "Priest Outfit", "Clothes", 1], + [704, "Wall Weapon Rack Blueprint", "Blueprint", 1], + [705, "Circus Pamphlet", "Document", 1], + [706, "Newspaper Cutout [Husbands]", "Document", 1], + [707, "Artifact", "Misc", 1], + [708, "Artifact Piece F", "Misc", 1], + [709, "Teleporter Blueprint", "Blueprint", 1], + [710, "Repel Shrine Blueprint", "Blueprint", 1], + [711, "Attract Shrine Blueprint", "Blueprint", 1], + [712, "Artifact Piece G", "Misc", 1], + [713, "Plater Counter Blueprint", "Blueprint", 1], + [714, "Gold Armor Plater Blueprint", "Blueprint", 1], + [715, "Raccoon Head", "Trophy", 1], + [716, "Bible Paper D", "Document", 1], + [717, "Old Note G", "Document", 1], + [718, "Personal Note E", "Document", 1], + [719, "Personal Note F", "Document", 1], + [720, "Personal Note G", "Document", 1], + [721, "Bible Paper Scrap A", "Document", 1], + [722, "Puffton Email B", "Document", 1], + [723, "Puffton Email C", "Document", 1], + [724, "Twinberry Seed", "Seed", 20], + [725, "Snowberry Seed", "Seed", 20], + [726, "Hang Glider Launcher Blueprint", "Blueprint", 1], + [727, "Solafite Armor", "Armor", 10], + [729, "Skunk Head", "Trophy", 1], + [730, "Small Rabbit Hutch Blueprint", "Blueprint", 1], + [749, "Stewardess Outfit", "Clothes", 1] +]; + +const SOTF_NAMES = { + "351": "Grab & Go Bag", + "357": "Airgun", + "366": "Tracker Dart", + "370": "Pistol Ammo", + "371": "Shotgun Buckshot", + "372": "Shotgun Slug", + "377": "Scope", + "384": "Crossbow Quiver", + "389": "MolotovAmmo", + "395": "Log Plank", + "406": "Quarter Log", + "408": "Half Log", + "409": "Three Quarter Log", + "427": "Printed Knife", + "429": "Printed Scuba Mask", + "458": "Battery Charger", + "460": "Battery Pack", + "463": "Flippers", + "470": "Airgun Scope", + "475": "Flask", + "478": "Climbing Axe", + "488": "Blue TShirt", + "497": "Food Tray", + "498": "GRABS Laser Pointer", + "505": "Laser Measure Tool", + "511": "Contact Trigger", + "512": "Filled Food Tray", + "513": "Spring", + "514": "Contact Spring Trigger", + "515": "Basic Building Tutorial Page", + "516": "Tarp Tent Tutorial Page", + "520": "Structure Element", + "559": "Printed Arrow Heads", + "561": "Printed Flower Pot", + "573": "Sleeping Bag", + "576": "Quarter Log Plank", + "577": "Half Log Plank", + "578": "Three Quarter Log Plank", + "583": "Storage Box Blueprint", + "592": "Creepy Skin", + "728": "Living Rabbit" +}; diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..ab8efb5 --- /dev/null +++ b/styles.css @@ -0,0 +1,434 @@ +:root { + --spruce: #0f1512; + --spruce-2: #161f1a; + --spruce-3: #1f2a23; + --moss: #6b7a4f; + --fog: #9aa39b; + --bone: #e4dccb; + --blood: #a3281c; + --blood-hi: #c23a2b; + --line: #2b382f; + --display: "IM Fell English", "Georgia", "Times New Roman", serif; + --body: "Barlow", "Segoe UI", "Helvetica Neue", Arial, sans-serif; +} + +* { box-sizing: border-box; } + +html, body { margin: 0; } + +body { + background: var(--spruce); + color: var(--bone); + font-family: var(--body); + font-size: 16px; + line-height: 1.5; + padding-bottom: 5rem; +} + +.hero { + position: relative; + padding: 4rem 1.5rem 7rem; + background: + radial-gradient(ellipse 80% 70% at 30% 10%, rgba(154, 163, 155, 0.22), transparent 70%), + radial-gradient(ellipse 60% 60% at 80% 30%, rgba(107, 122, 79, 0.18), transparent 70%), + linear-gradient(180deg, #27322b 0%, var(--spruce) 100%); + overflow: hidden; +} + +.hero::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 90px; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 60' preserveAspectRatio='none'%3E%3Cpath fill='%230f1512' d='M0 60 L0 44 L8 30 L5 30 L12 16 L9 16 L16 2 L23 16 L20 16 L27 30 L24 30 L32 44 L32 60 Z M30 60 L38 40 L35 40 L42 26 L39 26 L46 14 L53 26 L50 26 L57 40 L54 40 L62 60 Z M58 60 L66 36 L63 36 L71 20 L68 20 L76 6 L84 20 L81 20 L89 36 L86 36 L94 60 Z M90 60 L97 44 L94 44 L101 30 L98 30 L105 20 L112 30 L109 30 L116 44 L113 44 L120 60 Z'/%3E%3C/svg%3E") repeat-x bottom / 120px 90px; +} + +.hero-inner { + max-width: 1100px; + margin: 0 auto; +} + +h1 { + font-family: var(--display); + font-weight: 400; + font-size: clamp(3.5rem, 11vw, 7.5rem); + line-height: 0.9; + letter-spacing: -0.01em; + margin: 0; + color: var(--bone); + text-shadow: 0 0 40px rgba(228, 220, 203, 0.18); +} + +.subtitle { + font-family: var(--display); + font-style: italic; + font-size: 1.25rem; + color: var(--fog); + max-width: 34em; + margin: 1rem 0 0; +} + +main { + max-width: 1100px; + margin: 0 auto; + padding: 0 1.5rem; +} + +.loader { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem 1.5rem; + align-items: center; + padding: 1.5rem; + border: 1px dashed var(--moss); + border-radius: 4px; + background: var(--spruce-2); + transition: border-color 0.15s, background 0.15s; +} + +.loader.dragging { + border-color: var(--bone); + border-style: solid; + background: var(--spruce-3); +} + +.loader.loaded { + border-style: solid; + border-color: var(--line); +} + +.loader h2 { + font-family: var(--display); + font-weight: 400; + font-size: 1.75rem; + margin: 0; +} + +.loader p { + margin: 0.25rem 0 0; + color: var(--fog); +} + +.path-hint { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; + align-items: baseline; + font-size: 0.875rem; + color: var(--fog); + padding-top: 1rem; + border-top: 1px solid var(--line); +} + +.path-hint code { + font-family: Consolas, "Courier New", monospace; + color: var(--bone); + word-break: break-all; +} + +.btn { + font: 600 1rem var(--body); + color: var(--bone); + background: var(--spruce-3); + border: 1px solid var(--moss); + border-radius: 3px; + padding: 0.6rem 1.4rem; + cursor: pointer; +} + +.btn:hover { background: #29362d; } + +.btn-primary { + background: var(--blood); + border-color: var(--blood); +} + +.btn-primary:hover { background: var(--blood-hi); } + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn-link { + font: 500 0.875rem var(--body); + color: var(--bone); + background: none; + border: 0; + padding: 0.25rem; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.btn-link:disabled { + opacity: 0.4; + cursor: default; + text-decoration: none; +} + +:focus-visible { + outline: 2px solid var(--bone); + outline-offset: 2px; +} + +.toolbar { + position: sticky; + top: 0; + z-index: 5; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + padding: 1rem 0; + margin-top: 1.5rem; + background: var(--spruce); +} + +.toolbar input[type="search"] { + flex: 1 1 280px; + font: 1.05rem var(--body); + color: var(--bone); + background: var(--spruce-2); + border: 1px solid var(--line); + border-radius: 3px; + padding: 0.7rem 0.9rem; +} + +.toolbar input[type="search"]::placeholder { color: var(--fog); } + +.toolbar select { + font: 1rem var(--body); + color: var(--bone); + background: var(--spruce-2); + border: 1px solid var(--line); + border-radius: 3px; + padding: 0.7rem 0.6rem; +} + +.check { + display: flex; + gap: 0.4rem; + align-items: center; + cursor: pointer; +} + +.check input { accent-color: var(--blood); width: 1rem; height: 1rem; } + +.count-label { + margin-left: auto; + color: var(--fog); + font-size: 0.875rem; +} + +.table-wrap { position: relative; } + +table { + width: 100%; + border-collapse: collapse; + font-variant-numeric: tabular-nums; +} + +thead th { + position: sticky; + top: var(--toolbar-h, 4.4rem); + background: var(--spruce); + border-bottom: 1px solid var(--moss); + text-align: left; + padding: 0; + z-index: 4; +} + +thead th button { + font: 600 0.9rem var(--body); + color: var(--fog); + background: none; + border: 0; + padding: 0.6rem 0.75rem; + cursor: pointer; + width: 100%; + text-align: inherit; +} + +thead th button:hover { color: var(--bone); } + +thead th[aria-sort] button { color: var(--bone); } + +thead th[aria-sort="ascending"] button::after { content: " \25B2"; font-size: 0.7em; } +thead th[aria-sort="descending"] button::after { content: " \25BC"; font-size: 0.7em; } + +.col-id, .col-max, .col-count { text-align: right; } +.col-count { width: 12rem; } +.col-max { width: 8rem; } +.col-id { width: 5rem; } + +tbody td { + padding: 0.45rem 0.75rem; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} + +tbody tr { border-left: 3px solid transparent; } + +tbody tr.changed td:first-child { box-shadow: inset 3px 0 0 var(--blood); } +tbody tr.changed .cell-name { color: #fff; } + +tbody tr.zero .cell-name { color: var(--fog); } + +.cell-name { font-weight: 500; } +.cell-cat { color: var(--fog); } +.cell-id { color: var(--fog); text-align: right; } + +.pill { + display: inline-block; + margin-left: 0.5rem; + padding: 0 0.45rem; + font-size: 0.75rem; + border: 1px solid var(--moss); + border-radius: 999px; + color: var(--fog); + vertical-align: 0.1em; +} + +.pill-warn { border-color: var(--blood); color: #e0a39a; } + +.stepper { + display: inline-flex; + align-items: stretch; + float: right; +} + +.stepper button { + font: 600 1.05rem var(--body); + width: 2rem; + color: var(--bone); + background: var(--spruce-3); + border: 1px solid var(--line); + cursor: pointer; +} + +.stepper button:first-child { border-radius: 3px 0 0 3px; } +.stepper button:last-child { border-radius: 0 3px 3px 0; } +.stepper button:hover:not(:disabled) { background: #2d3b31; } +.stepper button:disabled { opacity: 0.35; cursor: default; } + +.stepper input { + width: 5.5rem; + font: 500 1rem var(--body); + font-variant-numeric: tabular-nums; + text-align: center; + color: var(--bone); + background: var(--spruce); + border: 1px solid var(--line); + border-left: 0; + border-right: 0; + padding: 0.3rem 0.25rem; + appearance: textfield; + -moz-appearance: textfield; +} + +.stepper input::-webkit-outer-spin-button, +.stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } + +.stepper input:disabled { color: var(--fog); } + +.cell-max { + text-align: right; + white-space: nowrap; + color: var(--fog); +} + +.cell-max .btn-link { margin-right: 0.5rem; } + +.empty { + text-align: center; + color: var(--fog); + padding: 3rem 1rem; +} + +.savebar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 10; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.75rem max(1.5rem, calc((100vw - 1100px) / 2 + 1.5rem)); + background: rgba(15, 21, 18, 0.94); + border-top: 1px solid var(--line); + backdrop-filter: blur(6px); +} + +.savebar-actions { display: flex; gap: 1rem; align-items: center; } + +#changeSummary { color: var(--fog); } +#changeSummary.has-changes { color: var(--bone); } + +dialog { + max-width: 32rem; + width: calc(100% - 2rem); + color: var(--bone); + background: var(--spruce-2); + border: 1px solid var(--moss); + border-radius: 4px; + padding: 1.75rem; +} + +dialog::backdrop { background: rgba(5, 8, 6, 0.8); } + +dialog h2 { + font-family: var(--display); + font-weight: 400; + font-size: 2rem; + margin: 0 0 0.75rem; +} + +dialog p { margin: 0 0 0.9rem; line-height: 1.55; } + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + align-items: center; + margin-top: 1.25rem; +} + +.toast { + position: fixed; + left: 50%; + bottom: 5rem; + transform: translate(-50%, 1rem); + max-width: min(36rem, calc(100% - 2rem)); + padding: 0.8rem 1.1rem; + background: var(--spruce-3); + border: 1px solid var(--moss); + border-radius: 3px; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 20; +} + +.toast.show { opacity: 1; transform: translate(-50%, 0); } +.toast.error { border-color: var(--blood); } + +@media (max-width: 720px) { + .hero { padding: 2.5rem 1rem 5.5rem; } + main { padding: 0 1rem; } + .loader { grid-template-columns: 1fr; } + .col-cat, .cell-cat, .col-id, .cell-id { display: none; } + .col-count { width: auto; } + .stepper input { width: 4rem; } + .cell-max .btn-link { display: none; } + .count-label { margin-left: 0; } +} + +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; } +}