Initial SOTF inventory editor for GitHub Pages

This commit is contained in:
Justin Oros
2026-09-21 19:42:31 -07:00
commit b9301879e7
5 changed files with 1445 additions and 0 deletions
+41
View File
@@ -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\<SteamID>\SinglePlayer\<SaveID>\`
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.
+486
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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('<span class="pill">equipped</span>');
if (!i.known) pills.push('<span class="pill pill-warn" title="Not in the item list. Keep it as is unless you know what it is.">unknown</span>');
return `<tr data-id="${i.id}" class="${rowClass(i)}">
<td class="cell-name">${escapeHtml(i.name)}${pills.join("")}</td>
<td class="cell-cat">${escapeHtml(i.category)}</td>
<td class="cell-id">${i.id}</td>
<td class="cell-count">
<div class="stepper">
<button type="button" data-step="-1" aria-label="Remove one ${escapeHtml(i.name)}" ${locked || i.count <= 0 ? "disabled" : ""}>-</button>
<input type="number" inputmode="numeric" min="0" max="${i.max}" value="${i.count}" aria-label="${escapeHtml(i.name)} count" ${locked ? "disabled" : ""}>
<button type="button" data-step="1" aria-label="Add one ${escapeHtml(i.name)}" ${locked || i.count >= i.max ? "disabled" : ""}>+</button>
</div>
</td>
<td class="cell-max"><button type="button" class="btn-link" data-fill ${locked || i.count >= i.max ? "disabled" : ""}>Fill</button>${i.max.toLocaleString()}</td>
</tr>`;
}
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();
})();
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SOTF Inventory Editor</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌲</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600&family=IM+Fell+English:ital@0;1&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="hero">
<div class="hero-inner">
<h1>Inventory</h1>
<p class="subtitle">A save editor for Sons of the Forest. Load your save, set your counts, download the new file.</p>
</div>
</header>
<main>
<section class="loader" id="dropZone" tabindex="0" aria-label="Load a save file">
<div class="loader-text">
<h2 id="loaderTitle">Drop your SaveData.zip here</h2>
<p id="loaderStatus">No save loaded. You can browse and edit items, but saving needs a loaded save.</p>
</div>
<div class="loader-actions">
<button type="button" class="btn" id="browseBtn">Browse</button>
<input type="file" id="fileInput" accept=".zip,.json" hidden>
</div>
<div class="path-hint">
<span>Saves folder</span>
<code id="savesPath"></code>
<button type="button" class="btn-link" id="copyPathBtn">Copy path</button>
</div>
</section>
<section class="toolbar">
<input type="search" id="search" placeholder="Search items by name or ID" autocomplete="off" aria-label="Search items">
<select id="category" aria-label="Filter by category">
<option value="">All categories</option>
</select>
<label class="check">
<input type="checkbox" id="ownedOnly">
<span>Owned only</span>
</label>
<span class="count-label" id="resultCount"></span>
</section>
<div class="table-wrap">
<table id="inventory">
<thead>
<tr>
<th data-sort="name" class="col-name"><button type="button">Item</button></th>
<th data-sort="category" class="col-cat"><button type="button">Category</button></th>
<th data-sort="id" class="col-id"><button type="button">ID</button></th>
<th data-sort="count" class="col-count"><button type="button">Count</button></th>
<th data-sort="max" class="col-max"><button type="button">Max</button></th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
<p class="empty" id="emptyState" hidden>No items match that search. Clear the search or pick another category.</p>
</div>
</main>
<footer class="savebar">
<span id="changeSummary">No changes</span>
<div class="savebar-actions">
<button type="button" class="btn-link" id="resetBtn" disabled>Undo changes</button>
<button type="button" class="btn btn-primary" id="saveBtn" disabled>Save</button>
</div>
</footer>
<dialog id="disclaimer">
<h2>Close the game first</h2>
<p>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.</p>
<p>Unequip items in game before you save and quit. Equipped items are shown here but can't be edited.</p>
<p>This is a fan tool with no connection to Endnight Games. Your save never leaves your computer.</p>
<div class="dialog-actions">
<button type="button" class="btn btn-primary" id="disclaimerOk">I understand</button>
</div>
</dialog>
<dialog id="saveDialog">
<h2>Download the new save</h2>
<p>You'll get a new <strong id="saveFileName">SaveData.zip</strong> with your changes.</p>
<p>Keep your original file as a backup. Copy it somewhere safe before you replace it, in case the modified save doesn't load.</p>
<p>Then put the new file in your save folder, replacing the old one, and start the game.</p>
<div class="dialog-actions">
<button type="button" class="btn-link" id="saveCancel">Cancel</button>
<button type="button" class="btn btn-primary" id="saveConfirm">Download</button>
</div>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="items.js"></script>
<script src="app.js"></script>
</body>
</html>
+382
View File
@@ -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"
};
+434
View File
@@ -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; }
}