Compare commits

...
10 Commits
6 changed files with 339 additions and 71 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
.DS_Store
+18
View File
@@ -0,0 +1,18 @@
& {
$saves = Join-Path $env:USERPROFILE 'AppData\LocalLow\Endnight\SonsOfTheForest\Saves'
if (-not (Test-Path -LiteralPath $saves)) {
Write-Host "Sons of the Forest saves folder not found: $saves" -ForegroundColor Red
return
}
$latest = Get-ChildItem -LiteralPath $saves -Filter 'SaveData.zip' -File -Recurse -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $latest) {
Write-Host "No SaveData.zip found under $saves" -ForegroundColor Red
return
}
Write-Host "Latest save: $($latest.FullName)" -ForegroundColor Green
Write-Host "Last saved: $($latest.LastWriteTime)"
Write-Host "Opening Explorer. Drag SaveData.zip onto the sotf-inventory page."
Start-Process explorer.exe -ArgumentList "/select,`"$($latest.FullName)`""
}
+9
View File
@@ -14,6 +14,14 @@ Live page: https://justinoros.github.io/sotf-inventory/
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.
Can't find your save? Close the game, open PowerShell and paste:
```
irm https://raw.githubusercontent.com/JustinOros/sotf-inventory/main/Find-SaveData.ps1 | iex
```
It opens Explorer with your most recent `SaveData.zip` selected.
A loose `PlayerInventorySaveData.json` also works if you prefer to edit the extracted file.
## Notes
@@ -33,6 +41,7 @@ Push the repo, then go to Settings, Pages, and set the source to the `main` bran
* `styles.css` theme
* `app.js` load, edit, and save logic
* `items.js` item IDs, names, categories, and max counts
* `Find-SaveData.ps1` opens Explorer at your latest `SaveData.zip`
## Credits
+143 -14
View File
@@ -2,24 +2,31 @@
const DISCLAIMER_KEY = "sotf-inventory-disclaimer-v1";
const SAVES_PATH = "%USERPROFILE%\\AppData\\LocalLow\\Endnight\\SonsOfTheForest\\Saves";
const INVENTORY_FILE = "PlayerInventorySaveData.json";
const KEEP_ON_CLEAR = new Set([351, 379, 380, 402, 412, 413, 483, 486, 552, 589]);
const FIRST_PAGE_BLUEPRINT = 665;
const $ = (id) => document.getElementById(id);
const els = {
dropZone: $("dropZone"),
dropTarget: $("dropTarget"),
loaderTitle: $("loaderTitle"),
loaderStatus: $("loaderStatus"),
browseBtn: $("browseBtn"),
fileInput: $("fileInput"),
copyPathBtn: $("copyPathBtn"),
copyCmdBtn: $("copyCmdBtn"),
helperCmd: $("helperCmd"),
search: $("search"),
category: $("category"),
ownedOnly: $("ownedOnly"),
changedOnly: $("changedOnly"),
resultCount: $("resultCount"),
rows: $("rows"),
table: $("inventory"),
emptyState: $("emptyState"),
changeSummary: $("changeSummary"),
resetBtn: $("resetBtn"),
clearBtn: $("clearBtn"),
saveBtn: $("saveBtn"),
disclaimer: $("disclaimer"),
disclaimerOk: $("disclaimerOk"),
@@ -38,6 +45,7 @@
query: "",
category: "",
ownedOnly: false,
changedOnly: false,
source: null
};
@@ -87,6 +95,7 @@
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 (state.changedOnly && i.count === i.original) return false;
if (!q) return true;
return i.name.toLowerCase().includes(q) || (qNum !== null && i.id === qNum);
});
@@ -157,12 +166,17 @@
return [...state.items.values()].filter((i) => i.count !== i.original);
}
function clearableItems() {
return [...state.items.values()].filter((i) => !i.equipped && !KEEP_ON_CLEAR.has(i.id));
}
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.clearBtn.disabled = !clearableItems().some((i) => i.count > 0);
els.saveBtn.disabled = !state.source;
els.saveBtn.title = state.source ? "" : "Load a save first";
}
@@ -199,7 +213,60 @@
return { outer, inner, innerIsString };
}
function applyInventory(inner) {
function isBlueprintPage(item) {
return item.category === "Blueprint" && item.id >= FIRST_PAGE_BLUEPRINT;
}
async function readWrapped(zip, fileName, key) {
const entry = Object.values(zip.files).find((f) => !f.dir && f.name.split("/").pop() === fileName);
if (!entry) return null;
try {
const outer = JSON.parse(await entry.async("string"));
const raw = outer && outer.Data && outer.Data[key];
if (raw === undefined) return null;
const innerIsString = typeof raw === "string";
const inner = innerIsString ? JSON.parse(raw) : raw;
return { path: entry.name, key, outer, inner, innerIsString };
} catch {
return null;
}
}
function writeWrapped(zip, w) {
w.outer.Data[w.key] = w.innerIsString ? JSON.stringify(w.inner) : w.inner;
zip.file(w.path, JSON.stringify(w.outer));
}
function unlockedPages(playerState) {
const ids = new Set();
const entries = playerState && Array.isArray(playerState.inner._entries) ? playerState.inner._entries : [];
for (const e of entries) {
const m = /^DiscoverablePageUnlocked_(\d+)$/.exec(e && e.Name);
if (m && e.BoolValue === true) ids.add(Number(m[1]));
}
return ids;
}
function describeGame(gameState) {
if (!gameState) return "";
const g = gameState.inner;
const parts = [];
if (Number.isFinite(g.GameDays)) {
const hh = String(g.GameHours || 0).padStart(2, "0");
const mm = String(g.GameMinutes || 0).padStart(2, "0");
parts.push(`Day ${g.GameDays} at ${hh}:${mm}`);
}
if (g.GameType) parts.push(`${g.GameType} mode`);
if (g.CrashSite) parts.push(`${g.CrashSite} crash site`);
let text = parts.join(", ");
if (g.SaveTime) {
const d = new Date(String(g.SaveTime).replace(/(\.\d{3})\d+/, "$1"));
if (!Number.isNaN(d.getTime())) text += `${text ? ". " : ""}Last saved ${d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}`;
}
return text ? `${text}.` : "";
}
function applyInventory(inner, pages) {
buildCatalog();
const addUnknown = (id, count) => {
const name = SOTF_NAMES[id] || `Unknown item ${id}`;
@@ -221,6 +288,12 @@
item.equipped = true;
}
}
if (pages) {
for (const id of pages) {
const item = state.items.get(id);
if (item && isBlueprintPage(item)) item.count = Math.max(item.count, 1);
}
}
for (const item of state.items.values()) item.original = item.count;
ensureUnknownCategory();
}
@@ -242,14 +315,19 @@
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 };
const hotkeys = zip ? await readWrapped(zip, "HotkeysSaveData.json", "Hotkeys") : null;
const playerState = zip ? await readWrapped(zip, "PlayerStateSaveData.json", "PlayerState") : null;
const gameState = zip ? await readWrapped(zip, "GameStateSaveData.json", "GameState") : null;
const pages = playerState && Array.isArray(playerState.inner._entries) ? unlockedPages(playerState) : null;
applyInventory(parsed.inner, pages);
state.source = { kind: isJson ? "json" : "zip", zip, path, fileName: isJson ? INVENTORY_FILE : "SaveData.zip", hotkeys, playerState: pages ? playerState : null, gameState, ...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.`;
const info = describeGame(gameState);
els.loaderStatus.textContent = `${info ? info + " " : ""}${owned} item types in your inventory. Drop another file to switch saves.`;
render();
toast(`Loaded ${file.name}`);
} catch (err) {
@@ -258,12 +336,17 @@
}
}
function buildInventoryText() {
const { outer, inner, innerIsString } = state.source;
function applyChanges() {
const src = state.source;
const { inner } = src;
const blocks = inner.ItemInstanceManagerData.ItemBlocks;
const pagesInState = !!src.playerState;
const removed = new Set();
const pageChanges = [];
for (const item of changedItems()) {
if (item.equipped) continue;
const page = pagesInState && isBlueprintPage(item);
if (page) pageChanges.push(item);
const matches = blocks.filter((b) => Number(b.ItemId) === item.id);
if (item.count === 0) {
removed.add(item.id);
@@ -271,7 +354,7 @@
continue;
}
if (matches.length === 0) {
blocks.push({ ItemId: item.id, TotalCount: item.count, UniqueItems: [] });
if (!page) blocks.push({ ItemId: item.id, TotalCount: item.count, UniqueItems: [] });
continue;
}
const [first, ...rest] = matches;
@@ -285,8 +368,36 @@
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);
src.outer.Data.PlayerInventory = src.innerIsString ? JSON.stringify(inner) : inner;
const touched = [];
if (src.hotkeys && removed.size) {
const h = src.hotkeys.inner;
for (const key of ["RightHandItemIds", "LeftHandItemIds"]) {
if (Array.isArray(h[key])) h[key] = h[key].map((id) => (removed.has(Number(id)) ? -1 : id));
}
touched.push(src.hotkeys);
}
if (src.playerState && pageChanges.length) {
const entries = src.playerState.inner._entries;
const upsert = (name) => {
const e = entries.find((x) => x && x.Name === name);
if (e) e.BoolValue = true;
else entries.push({ Name: name, BoolValue: true });
};
for (const item of pageChanges) {
const unlockName = `DiscoverablePageUnlocked_${item.id}`;
if (item.count > 0) {
upsert(unlockName);
upsert(`hasOwned_${String(item.id).padStart(8, "0")}`);
} else {
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i] && entries[i].Name === unlockName) entries.splice(i, 1);
}
}
}
touched.push(src.playerState);
}
return { inventoryText: JSON.stringify(src.outer), touched };
}
function download(blob, name) {
@@ -302,11 +413,12 @@
async function doSave() {
try {
const text = buildInventoryText();
const { inventoryText: text, touched } = applyChanges();
const src = state.source;
let blob;
if (src.kind === "zip") {
src.zip.file(src.path, text);
for (const w of touched) writeWrapped(src.zip, w);
blob = await src.zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } });
} else {
blob = new Blob([text], { type: "application/json" });
@@ -347,12 +459,21 @@
els.copyPathBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(SAVES_PATH);
toast("Saves folder path copied. Paste it into the file dialog's address bar, then open your SteamID and save folders.");
toast("Path copied. Paste it into the file dialog's address bar, then open your SteamID and save folders.");
} catch {
toast("Couldn't copy. Select the path and copy it manually.", true);
}
});
els.copyCmdBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(els.helperCmd.textContent.trim());
toast("Command copied. Paste it into PowerShell and press Enter.");
} catch {
toast("Couldn't copy. Select the command and copy it manually.", true);
}
});
els.browseBtn.addEventListener("click", browse);
els.fileInput.addEventListener("change", () => loadFile(els.fileInput.files[0]));
@@ -375,8 +496,9 @@
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 === " ")) {
els.dropTarget.addEventListener("click", browse);
els.dropTarget.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
browse();
}
@@ -389,6 +511,7 @@
});
els.category.addEventListener("change", () => { state.category = els.category.value; render(); });
els.ownedOnly.addEventListener("change", () => { state.ownedOnly = els.ownedOnly.checked; render(); });
els.changedOnly.addEventListener("change", () => { state.changedOnly = els.changedOnly.checked; render(); });
els.table.querySelector("thead").addEventListener("click", (e) => {
const th = e.target.closest("th[data-sort]");
@@ -442,6 +565,12 @@
render();
});
els.clearBtn.addEventListener("click", () => {
for (const item of clearableItems()) item.count = 0;
render();
toast("Inventory cleared. Equipped items and starting tools were kept. Use Undo changes to restore.");
});
els.saveBtn.addEventListener("click", () => {
if (!state.source) return;
els.saveFileName.textContent = state.source.fileName;
+51 -12
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<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">
@@ -16,23 +17,56 @@
<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>
<svg class="treeline" aria-hidden="true" focusable="false">
<defs>
<pattern id="trees" width="120" height="90" patternUnits="userSpaceOnUse">
<path transform="scale(1 1.5)" fill="currentColor" 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"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#trees)"/>
</svg>
</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>
<section class="loader" id="dropZone" aria-label="Load a save file">
<div class="loader-head">
<h2 id="loaderTitle">Load your save</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 class="path-label">Your save file is here</span>
<code class="path"><span>%USERPROFILE%\AppData\LocalLow\Endnight\SonsOfTheForest\Saves\</span><var>SteamID</var><span>\SinglePlayer\</span><var>SaveID</var><span>\SaveData.zip</span></code>
<p class="path-note"><var>SteamID</var> is the long number folder. <var>SaveID</var> is one folder per save; sort by date modified to find your latest. Hosted co-op saves are under Multiplayer instead of SinglePlayer.</p>
<button type="button" class="btn-link" id="copyPathBtn">Copy Saves folder path</button>
<div class="options">
<div class="option">
<span class="option-tag">Option A</span>
<h3>Browse to your file</h3>
<p>Your save is here:</p>
<code class="path"><span>%USERPROFILE%\<wbr>AppData\<wbr>LocalLow\<wbr>Endnight\<wbr>SonsOfTheForest\<wbr>Saves\<wbr></span><var>SteamID</var><span>\<wbr>SinglePlayer\<wbr></span><var>SaveID</var><span>\<wbr>SaveData.zip</span></code>
<p class="hint"><var>SteamID</var> is the long number folder. Pick the newest <var>SaveID</var> folder. Co-op host saves are in Multiplayer.</p>
<div class="option-actions">
<button type="button" class="btn" id="browseBtn">Browse</button>
<button type="button" class="btn-link copy-btn" id="copyPathBtn"><svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg><span>Copy path</span></button>
</div>
<input type="file" id="fileInput" accept=".zip,.json" hidden>
</div>
<div class="option">
<span class="option-tag">Option B</span>
<h3>Use this PowerShell script</h3>
<p>Close the game, open PowerShell and paste:</p>
<code class="path" id="helperCmd">irm https://<wbr>raw.githubusercontent.com/<wbr>JustinOros/<wbr>sotf-inventory/<wbr>main/<wbr>Find-SaveData.ps1 | iex</code>
<p class="hint">Explorer opens with your latest SaveData.zip selected. Drag it onto this page.</p>
<div class="option-actions">
<button type="button" class="btn copy-btn" id="copyCmdBtn"><svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg><span>Copy command</span></button>
</div>
</div>
<div class="option option-drop" id="dropTarget" tabindex="0" role="button" aria-label="Drop SaveData.zip here or press Enter to browse">
<span class="option-tag">Option C</span>
<h3>Drag and drop</h3>
<div class="drop-well">
<svg viewBox="0 0 24 24" width="36" height="36" aria-hidden="true"><path d="M12 3v12m0 0-4.5-4.5M12 15l4.5-4.5M4 17v2.5A1.5 1.5 0 0 0 5.5 21h13a1.5 1.5 0 0 0 1.5-1.5V17" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>Drop your SaveData.zip here</span>
</div>
</div>
</div>
</section>
@@ -45,6 +79,10 @@
<input type="checkbox" id="ownedOnly">
<span>Owned only</span>
</label>
<label class="check">
<input type="checkbox" id="changedOnly">
<span>Changed only</span>
</label>
<span class="count-label" id="resultCount"></span>
</section>
@@ -68,6 +106,7 @@
<footer class="savebar">
<span id="changeSummary">No changes</span>
<div class="savebar-actions">
<button type="button" class="btn-link" id="clearBtn" title="Sets every item to 0 except equipped items and starting tools">Clear inventory</button>
<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>
+117 -45
View File
@@ -1,4 +1,5 @@
:root {
color-scheme: dark;
--spruce: #0f1512;
--spruce-2: #161f1a;
--spruce-3: #1f2a23;
@@ -35,14 +36,14 @@ body {
overflow: hidden;
}
.hero::after {
content: "";
.treeline {
position: absolute;
left: 0;
right: 0;
bottom: -1px;
width: 100%;
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;
color: var(--spruce);
display: block;
}
.hero-inner {
@@ -73,80 +74,150 @@ h1 {
main {
max-width: 1100px;
margin: 0 auto;
padding: 0 1.5rem;
padding: 2.5rem 1.5rem 0;
}
.loader {
display: grid;
grid-template-columns: 1fr auto;
gap: 1rem 1.5rem;
align-items: center;
padding: 1.5rem;
border: 1px dashed var(--moss);
padding: 1.75rem;
border: 1px solid var(--line);
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 {
.loader-head h2 {
font-family: var(--display);
font-weight: 400;
font-size: 1.75rem;
font-size: 1.9rem;
margin: 0;
}
.loader p {
.loader-head p {
margin: 0.25rem 0 0;
color: var(--fog);
}
.path-hint {
grid-column: 1 / -1;
.loader.loaded .loader-head h2 { color: #fff; }
.options {
display: grid;
gap: 0.4rem;
justify-items: start;
font-size: 0.875rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.25rem;
margin-top: 1.5rem;
}
.option {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 1.25rem;
background: var(--spruce);
border: 1px solid var(--line);
border-radius: 4px;
}
.option-tag {
font-size: 0.8rem;
font-weight: 600;
color: var(--moss);
}
.option h3 {
font-family: var(--display);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.2;
margin: -0.35rem 0 0.2rem;
}
.option p {
margin: 0;
color: var(--fog);
padding-top: 1rem;
border-top: 1px solid var(--line);
font-size: 0.925rem;
}
.option .hint {
font-size: 0.85rem;
line-height: 1.5;
}
.option-actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
margin-top: auto;
padding-top: 0.4rem;
}
.path {
display: block;
width: 100%;
font-family: Consolas, "Courier New", monospace;
font-size: 0.95rem;
font-size: 0.8rem;
line-height: 1.55;
color: var(--bone);
background: var(--spruce);
background: var(--spruce-2);
border: 1px solid var(--line);
border-radius: 3px;
padding: 0.6rem 0.75rem;
overflow-wrap: anywhere;
padding: 0.55rem 0.7rem;
overflow-wrap: break-word;
user-select: all;
}
.path-hint var {
.option var {
font-style: normal;
color: #e0a39a;
border-bottom: 1px dotted var(--blood-hi);
}
.path-note {
margin: 0;
max-width: 60em;
.copy-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
text-decoration: none;
}
.btn-link.copy-btn { padding: 0; }
.copy-btn svg { flex: none; }
.btn.copy-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-link.copy-btn span {
text-decoration: underline;
text-underline-offset: 3px;
}
.option-drop {
cursor: pointer;
}
.drop-well {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
min-height: 11rem;
text-align: center;
color: var(--fog);
border: 2px dashed var(--moss);
border-radius: 4px;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.option-drop:hover .drop-well,
.loader.dragging .drop-well {
border-color: var(--bone);
color: var(--bone);
background: var(--spruce-3);
}
.loader.dragging { border-color: var(--moss); }
.btn {
font: 600 1rem var(--body);
color: var(--bone);
@@ -438,8 +509,9 @@ dialog p { margin: 0 0 0.9rem; line-height: 1.55; }
@media (max-width: 720px) {
.hero { padding: 2.5rem 1rem 5.5rem; }
main { padding: 0 1rem; }
.loader { grid-template-columns: 1fr; }
main { padding: 1.5rem 1rem 0; }
.options { grid-template-columns: 1fr; }
.drop-well { min-height: 7rem; }
.col-cat, .cell-cat, .col-id, .cell-id { display: none; }
.col-count { width: auto; }
.stepper input { width: 4rem; }