This commit is contained in:
2026-03-15 18:23:58 +02:00
parent 6bf221de3f
commit ad3385c63b
8 changed files with 319995 additions and 8 deletions

View File

@@ -6,11 +6,13 @@ Small Go scraper for the Outward Fandom wiki.
```text ```text
. .
├── cmd/outward-web/main.go # web UI entrypoint
├── cmd/scrappr/main.go # binary entrypoint ├── cmd/scrappr/main.go # binary entrypoint
├── internal/app # bootstrapping and output writing ├── internal/app # bootstrapping and output writing
├── internal/logx # colored emoji logger ├── internal/logx # colored emoji logger
├── internal/model # dataset models ├── internal/model # dataset models
├── internal/scraper # crawl flow, parsing, queueing, retries ├── internal/scraper # crawl flow, parsing, queueing, retries
├── internal/webui # embedded web server + static UI
├── go.mod ├── go.mod
├── go.sum ├── go.sum
└── outward_data.json # generated output └── outward_data.json # generated output
@@ -22,6 +24,10 @@ Small Go scraper for the Outward Fandom wiki.
go run ./cmd/scrappr go run ./cmd/scrappr
``` ```
```bash
go run ./cmd/outward-web
```
## What It Does ## What It Does
- Crawls item and crafting pages from `outward.fandom.com` - Crawls item and crafting pages from `outward.fandom.com`
@@ -32,6 +38,7 @@ go run ./cmd/scrappr
- Stores legacy and portable infobox fields, primary item image URLs, recipes, effects, and raw content tables for later processing - Stores legacy and portable infobox fields, primary item image URLs, recipes, effects, and raw content tables for later processing
- Saves resumable checkpoints into `.cache/scrape-state.json` on a timer, during progress milestones, and on `Ctrl+C` - Saves resumable checkpoints into `.cache/scrape-state.json` on a timer, during progress milestones, and on `Ctrl+C`
- Writes a stable, sorted JSON dataset to `outward_data.json` - Writes a stable, sorted JSON dataset to `outward_data.json`
- Serves a local craft-planner UI backed by recipes from `outward_data.json`
## Tuning ## Tuning

26
cmd/outward-web/main.go Normal file
View File

@@ -0,0 +1,26 @@
package main
import (
"os"
"scrappr/internal/logx"
"scrappr/internal/webui"
)
func main() {
addr := envOrDefault("SCRAPPR_ADDR", ":8080")
dataPath := envOrDefault("SCRAPPR_DATA", "outward_data.json")
if err := webui.Run(addr, dataPath); err != nil {
logx.Eventf("error", "fatal: %v", err)
os.Exit(1)
}
}
func envOrDefault(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}

View File

@@ -452,14 +452,26 @@ func (s *Scraper) parseImageURL(doc *goquery.Document) string {
func (s *Scraper) normalizeImageURL(raw string) string { func (s *Scraper) normalizeImageURL(raw string) string {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
switch { if raw == "" {
case raw == "":
return "" return ""
case strings.HasPrefix(raw, "//"):
return "https:" + raw
default:
return raw
} }
if strings.HasPrefix(raw, "//") {
raw = "https:" + raw
}
query := ""
if idx := strings.Index(raw, "?"); idx >= 0 {
query = raw[idx:]
raw = raw[:idx]
}
const scaledMarker = "/revision/latest/scale-to-width-down/"
if idx := strings.Index(raw, scaledMarker); idx >= 0 {
raw = raw[:idx] + "/revision/latest"
}
return raw + query
} }
func (s *Scraper) parseContentTables(doc *goquery.Document) []model.Table { func (s *Scraper) parseContentTables(doc *goquery.Document) []model.Table {

227
internal/webui/server.go Normal file
View File

@@ -0,0 +1,227 @@
package webui
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"sort"
"strings"
"scrappr/internal/logx"
"scrappr/internal/model"
)
//go:embed static/*
var staticFiles embed.FS
type Catalog struct {
ItemNames []string `json:"item_names"`
Items []CatalogItem `json:"items"`
Craftables []CraftableEntry `json:"craftables"`
}
type CatalogItem struct {
Name string `json:"name"`
URL string `json:"url"`
ImageURL string `json:"image_url,omitempty"`
Categories []string `json:"categories,omitempty"`
ItemType string `json:"item_type,omitempty"`
}
type CraftableEntry struct {
CatalogItem
Recipes []model.Recipe `json:"recipes"`
}
func Run(addr, dataPath string) error {
catalog, err := loadCatalog(dataPath)
if err != nil {
return err
}
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
return err
}
mux := http.NewServeMux()
mux.HandleFunc("/", serveStaticFile(staticFS, "index.html", "text/html; charset=utf-8"))
mux.HandleFunc("/styles.css", serveStaticFile(staticFS, "styles.css", "text/css; charset=utf-8"))
mux.HandleFunc("/app.js", serveStaticFile(staticFS, "app.js", "application/javascript; charset=utf-8"))
mux.HandleFunc("/api/catalog", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
setNoCache(w)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(catalog); err != nil {
logx.Eventf("error", "catalog encode failed: %v", err)
}
})
logx.Eventf(
"start",
"web UI listening on %s using %s (%d craftable items)",
displayAddr(addr),
dataPath,
len(catalog.Craftables),
)
return http.ListenAndServe(addr, mux)
}
func loadCatalog(dataPath string) (Catalog, error) {
file, err := os.Open(dataPath)
if err != nil {
return Catalog{}, fmt.Errorf("open dataset %s: %w", dataPath, err)
}
defer file.Close()
var dataset model.Dataset
if err := json.NewDecoder(file).Decode(&dataset); err != nil {
return Catalog{}, fmt.Errorf("decode dataset %s: %w", dataPath, err)
}
return buildCatalog(dataset), nil
}
func buildCatalog(dataset model.Dataset) Catalog {
itemNames := make([]string, 0, len(dataset.Items))
items := make([]CatalogItem, 0, len(dataset.Items))
craftableByName := map[string]*CraftableEntry{}
itemByName := map[string]CatalogItem{}
seen := map[string]bool{}
for _, item := range dataset.Items {
name := strings.TrimSpace(item.Name)
if name != "" && !seen[name] {
seen[name] = true
itemNames = append(itemNames, name)
}
catalogItem := CatalogItem{
Name: item.Name,
URL: item.URL,
ImageURL: item.ImageURL,
Categories: item.Categories,
ItemType: item.Infobox["Type"],
}
items = append(items, catalogItem)
itemByName[strings.ToLower(strings.TrimSpace(item.Name))] = catalogItem
}
for _, item := range dataset.Items {
for _, recipe := range item.Recipes {
resultKey := strings.ToLower(strings.TrimSpace(recipe.Result))
if resultKey == "" {
continue
}
entry := craftableByName[resultKey]
if entry == nil {
base := itemByName[resultKey]
if strings.TrimSpace(base.Name) == "" {
base = CatalogItem{
Name: recipe.Result,
URL: item.URL,
ImageURL: item.ImageURL,
}
}
entry = &CraftableEntry{
CatalogItem: base,
}
craftableByName[resultKey] = entry
}
if !hasRecipe(entry.Recipes, recipe) {
entry.Recipes = append(entry.Recipes, recipe)
}
}
}
sort.Strings(itemNames)
sort.Slice(items, func(i, j int) bool {
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
})
craftables := make([]CraftableEntry, 0, len(craftableByName))
for _, entry := range craftableByName {
craftables = append(craftables, *entry)
}
sort.Slice(craftables, func(i, j int) bool {
return strings.ToLower(craftables[i].Name) < strings.ToLower(craftables[j].Name)
})
return Catalog{
ItemNames: itemNames,
Items: items,
Craftables: craftables,
}
}
func hasRecipe(existing []model.Recipe, target model.Recipe) bool {
for _, recipe := range existing {
if recipe.Result != target.Result || recipe.ResultCount != target.ResultCount || recipe.Station != target.Station {
continue
}
if len(recipe.Ingredients) != len(target.Ingredients) {
continue
}
match := true
for i := range recipe.Ingredients {
if recipe.Ingredients[i] != target.Ingredients[i] {
match = false
break
}
}
if match {
return true
}
}
return false
}
func displayAddr(addr string) string {
if strings.HasPrefix(addr, ":") {
return "http://localhost" + addr
}
if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
return addr
}
return "http://" + addr
}
func serveStaticFile(staticFS fs.FS, name, contentType string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && "/"+name != r.URL.Path {
http.NotFound(w, r)
return
}
content, err := fs.ReadFile(staticFS, name)
if err != nil {
http.Error(w, "asset missing", http.StatusInternalServerError)
return
}
setNoCache(w)
w.Header().Set("Content-Type", contentType)
_, _ = w.Write(content)
}
}
func setNoCache(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}

View File

@@ -0,0 +1,435 @@
const state = {
catalog: null,
itemByKey: new Map(),
};
const inventoryInput = document.querySelector("#inventory-input");
const quickItemInput = document.querySelector("#quick-item");
const quickQtyInput = document.querySelector("#quick-qty");
const itemList = document.querySelector("#item-list");
const summaryText = document.querySelector("#summary-text");
const resultsRoot = document.querySelector("#results");
const chipsRoot = document.querySelector("#inventory-chips");
document.querySelector("#analyze-btn").addEventListener("click", render);
document.querySelector("#sample-btn").addEventListener("click", () => {
inventoryInput.value = [
"Water",
"2x Ableroot",
"2x Oil",
"2x Gravel Beetle",
"Linen Cloth",
"Seaweed",
"Salt",
].join("\n");
render();
});
document.querySelector("#clear-btn").addEventListener("click", () => {
inventoryInput.value = "";
quickItemInput.value = "";
quickQtyInput.value = "1";
render();
});
document.querySelector("#add-item").addEventListener("click", addQuickItem);
quickItemInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
addQuickItem();
}
});
inventoryInput.addEventListener("input", renderInventoryPreview);
bootstrap().catch((error) => {
summaryText.textContent = "Failed to load crafting catalog.";
resultsRoot.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`;
});
async function bootstrap() {
const response = await fetch("/api/catalog");
if (!response.ok) {
throw new Error(`Catalog request failed with ${response.status}`);
}
state.catalog = await response.json();
state.itemByKey = buildItemIndex(state.catalog.items || []);
hydrateItemList(state.catalog.item_names || []);
summaryText.textContent = `Loaded ${state.catalog.item_names.length} known items and ${state.catalog.craftables.length} craftable entries.`;
render();
}
function buildItemIndex(items) {
const index = new Map();
for (const item of items) {
index.set(normalizeName(item.name), item);
}
return index;
}
function hydrateItemList(names) {
itemList.innerHTML = names.map((name) => `<option value="${escapeHtml(name)}"></option>`).join("");
}
function addQuickItem() {
const name = quickItemInput.value.trim();
const qty = Math.max(1, Number.parseInt(quickQtyInput.value || "1", 10) || 1);
if (!name) {
return;
}
const line = qty > 1 ? `${qty}x ${name}` : name;
inventoryInput.value = inventoryInput.value.trim()
? `${inventoryInput.value.trim()}\n${line}`
: line;
quickItemInput.value = "";
quickQtyInput.value = "1";
render();
}
function render() {
renderInventoryPreview();
if (!state.catalog) {
return;
}
const inventory = parseInventory(inventoryInput.value);
const craftable = findCraftableRecipes(state.catalog.craftables, inventory);
if (!craftable.length) {
summaryText.textContent = `No craftable recipes found from ${inventory.totalKinds} inventory item types.`;
resultsRoot.innerHTML = `
<div class="empty-state">
Add more ingredients or load the sample inventory to see matching recipes.
</div>
`;
return;
}
summaryText.textContent = `${craftable.length} recipe matches from ${inventory.totalKinds} inventory item types.`;
resultsRoot.innerHTML = craftable.map(renderRecipeCard).join("");
wireResultImages();
}
function renderInventoryPreview() {
const inventory = parseInventory(inventoryInput.value);
const entries = Object.values(inventory.byKey).sort((a, b) => a.name.localeCompare(b.name));
if (!entries.length) {
chipsRoot.innerHTML = `<div class="chip">No inventory entered yet</div>`;
return;
}
chipsRoot.innerHTML = entries
.map((entry) => `<div class="chip">${escapeHtml(entry.display)}</div>`)
.join("");
}
function parseInventory(raw) {
const byKey = {};
const lines = raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const parsed = parseStackText(line);
if (!parsed.name) {
continue;
}
const key = normalizeName(parsed.name);
if (!byKey[key]) {
byKey[key] = {
name: parsed.name,
count: 0,
display: "",
};
}
byKey[key].count += parsed.count;
byKey[key].display = `${byKey[key].count}x ${byKey[key].name}`;
}
return {
byKey,
totalKinds: Object.keys(byKey).length,
};
}
function findCraftableRecipes(items, inventory) {
const matches = [];
for (const item of items) {
for (const recipe of item.recipes || []) {
if (normalizeName(recipe.result || "") !== normalizeName(item.name || "")) {
continue;
}
const requirements = collapseRequirements(recipe.ingredients || []);
if (!requirements.length) {
continue;
}
let maxCrafts = Number.POSITIVE_INFINITY;
let canCraft = true;
for (const requirement of requirements) {
const have = availableCountForIngredient(requirement.name, inventory);
if (have < requirement.count) {
canCraft = false;
break;
}
maxCrafts = Math.min(maxCrafts, Math.floor(have / requirement.count));
}
if (!canCraft) {
continue;
}
matches.push({
item,
recipe,
maxCrafts: Number.isFinite(maxCrafts) ? maxCrafts : 0,
resultYield: parseResultYield(recipe.result_count),
});
}
}
return matches.sort((left, right) => {
if (right.maxCrafts !== left.maxCrafts) {
return right.maxCrafts - left.maxCrafts;
}
return left.item.name.localeCompare(right.item.name);
});
}
function renderRecipeCard(entry) {
const initials = escapeHtml(entry.item.name.slice(0, 2).toUpperCase());
const ingredients = (entry.recipe.ingredients || [])
.map((ingredient) => `<span class="ingredient-pill">${escapeHtml(ingredient)}</span>`)
.join("");
const canonicalImageUrl = normalizeImageUrl(entry.item.image_url || "");
const image = canonicalImageUrl
? `<img src="${escapeHtml(canonicalImageUrl)}" data-fallback-src="${escapeHtml(fallbackImageUrl(canonicalImageUrl))}" alt="${escapeHtml(entry.item.name)}">`
: `<div class="result-fallback">${initials}</div>`;
const station = entry.recipe.station ? `Station: ${escapeHtml(entry.recipe.station)}` : "Station: Any";
const yieldLabel = entry.resultYield > 1 ? `${entry.resultYield}x per craft` : "1x per craft";
return `
<article class="result-card">
${image}
<div>
<div class="result-top">
<div>
<h3 class="result-name">
<a href="${escapeHtml(entry.item.url)}" target="_blank" rel="noreferrer">${escapeHtml(entry.item.name)}</a>
</h3>
<p class="meta">${station} · ${yieldLabel}</p>
</div>
<div class="recipe-badge">${entry.maxCrafts} craft${entry.maxCrafts === 1 ? "" : "s"} possible</div>
</div>
<div class="ingredients">${ingredients}</div>
</div>
</article>
`;
}
function wireResultImages() {
const images = resultsRoot.querySelectorAll("img[data-fallback-src]");
for (const image of images) {
image.onerror = () => {
const fallback = image.dataset.fallbackSrc || "";
if (!fallback || image.dataset.failedOnce === "1") {
image.onerror = null;
image.replaceWith(createFallbackNode(image.alt || "?"));
return;
}
image.dataset.failedOnce = "1";
image.src = fallback;
};
}
}
function createFallbackNode(label) {
const node = document.createElement("div");
node.className = "result-fallback";
node.textContent = String(label).slice(0, 2).toUpperCase();
return node;
}
function parseStackText(raw) {
const cleaned = raw.replace(/×/g, "x").trim();
if (!cleaned) {
return { name: "", count: 0 };
}
const prefixed = cleaned.match(/^(\d+)\s*x\s+(.+)$/i);
if (prefixed) {
return {
count: Number.parseInt(prefixed[1], 10),
name: prefixed[2].trim(),
};
}
return {
count: 1,
name: cleaned,
};
}
function parseResultYield(raw) {
const parsed = parseStackText(raw || "1x Result");
return parsed.count || 1;
}
function normalizeImageUrl(raw) {
const value = String(raw || "").trim();
if (!value) {
return "";
}
const [base, query = ""] = value.split("?");
const canonicalBase = base.replace(/\/revision\/latest\/scale-to-width-down\/\d+$/, "/revision/latest");
return query ? `${canonicalBase}?${query}` : canonicalBase;
}
function fallbackImageUrl(raw) {
const value = normalizeImageUrl(raw);
if (!value) {
return "";
}
const [base] = value.split("?");
return base.replace(/\/revision\/latest$/, "");
}
function collapseRequirements(ingredients) {
const counts = new Map();
for (const ingredient of ingredients) {
const parsed = parseStackText(ingredient);
if (!parsed.name) {
continue;
}
const key = normalizeName(parsed.name);
const existing = counts.get(key) || {
name: parsed.name,
count: 0,
};
existing.count += parsed.count;
counts.set(key, existing);
}
return Array.from(counts.values());
}
function availableCountForIngredient(ingredientName, inventory) {
const requirementKey = normalizeName(ingredientName);
if (requirementKey === "water") {
return countInventory(inventory, ["water", "clean water"]);
}
if (requirementKey === "meat") {
return countMatchingInventory(inventory, (item) => isMeatLike(item));
}
if (requirementKey === "vegetable") {
return countMatchingInventory(inventory, (item) => isVegetableLike(item));
}
if (requirementKey === "ration ingredient") {
return countMatchingInventory(inventory, (item) => isRationIngredient(item));
}
return inventory.byKey[requirementKey]?.count || 0;
}
function countInventory(inventory, keys) {
return keys.reduce((sum, key) => sum + (inventory.byKey[key]?.count || 0), 0);
}
function countMatchingInventory(inventory, predicate) {
let total = 0;
for (const [key, entry] of Object.entries(inventory.byKey)) {
if (!predicate(resolveInventoryItem(key, entry.name))) {
continue;
}
total += entry.count;
}
return total;
}
function resolveInventoryItem(key, fallbackName) {
const item = state.itemByKey.get(key);
if (item) {
return item;
}
return {
name: fallbackName,
categories: [],
item_type: "",
};
}
function isMeatLike(item) {
const name = normalizeName(item.name);
return name.includes("meat") || name.includes("jerky");
}
function isVegetableLike(item) {
const name = normalizeName(item.name);
const categories = normalizeList(item.categories || []);
const itemType = normalizeName(item.item_type || "");
const foodLike = categories.includes("food") || categories.includes("consumables") || itemType === "food";
if (!foodLike) {
return false;
}
if (name.includes("salt") || name.includes("water") || name.includes("meat") || name.includes("fish") || name.includes("egg")) {
return false;
}
return true;
}
function isRationIngredient(item) {
const name = normalizeName(item.name);
const categories = normalizeList(item.categories || []);
const itemType = normalizeName(item.item_type || "");
const foodLike = categories.includes("food") || categories.includes("consumables") || itemType === "food";
if (!foodLike) {
return false;
}
return !name.includes("salt") && !name.includes("water");
}
function normalizeList(values) {
return values.map((value) => normalizeName(value));
}
function normalizeName(value) {
return value.toLowerCase().replace(/\s+/g, " ").trim();
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

View File

@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Outward Craft Planner</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="app-shell">
<section class="hero">
<p class="eyebrow">Outward Craft Planner</p>
<h1>Tell me what is in your bag, and I will tell you what you can craft.</h1>
<p class="hero-copy">
One item per line. Use <code>2x Linen Cloth</code> if you have more than one.
</p>
</section>
<section class="workspace">
<aside class="panel input-panel">
<div class="panel-head">
<h2>Inventory</h2>
<p>Quick add or paste a full list.</p>
</div>
<div class="quick-add">
<input id="quick-item" list="item-list" type="text" placeholder="Search item name">
<input id="quick-qty" type="number" min="1" value="1">
<button id="add-item" type="button">Add</button>
<datalist id="item-list"></datalist>
</div>
<textarea id="inventory-input" spellcheck="false" placeholder="Water&#10;2x Linen Cloth&#10;Gravel Beetle&#10;Oil"></textarea>
<div class="actions">
<button id="analyze-btn" class="primary" type="button">Analyze Crafting</button>
<button id="sample-btn" type="button">Load Sample</button>
<button id="clear-btn" type="button">Clear</button>
</div>
<div class="inventory-preview">
<div class="mini-title">Parsed inventory</div>
<div id="inventory-chips" class="chips"></div>
</div>
</aside>
<section class="panel output-panel">
<div class="panel-head">
<h2>Craftable Items</h2>
<p id="summary-text">Loading dataset…</p>
</div>
<div id="results" class="results"></div>
</section>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,311 @@
:root {
--bg: #f3ecdf;
--bg-strong: #e6d8bd;
--panel: rgba(255, 251, 244, 0.9);
--panel-border: rgba(74, 52, 24, 0.14);
--ink: #2f2417;
--muted: #6c5a43;
--accent: #9b4d20;
--accent-strong: #7a3912;
--accent-soft: #ecd4b6;
--success: #2f7a42;
--shadow: 0 24px 60px rgba(56, 38, 16, 0.12);
--radius: 22px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(193, 121, 52, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(80, 128, 91, 0.18), transparent 24%),
linear-gradient(180deg, #f8f3e8 0%, var(--bg) 46%, #ece0c9 100%);
font-family: "Trebuchet MS", "Segoe UI Variable Text", "Segoe UI", sans-serif;
}
code,
textarea,
input,
button {
font: inherit;
}
.app-shell {
width: min(1180px, calc(100vw - 32px));
margin: 0 auto;
padding: 36px 0 48px;
}
.hero {
padding: 24px 4px 28px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--accent);
font-size: 0.82rem;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.hero h1 {
margin: 0;
max-width: 840px;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
font-size: clamp(2rem, 4.8vw, 3.8rem);
line-height: 1;
}
.hero-copy {
max-width: 760px;
margin: 16px 0 0;
color: var(--muted);
font-size: 1.03rem;
line-height: 1.6;
}
.workspace {
display: grid;
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
gap: 22px;
}
.panel {
backdrop-filter: blur(12px);
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.input-panel,
.output-panel {
padding: 22px;
}
.panel-head h2 {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
font-size: 1.7rem;
}
.panel-head p {
margin: 8px 0 0;
color: var(--muted);
}
.quick-add {
display: grid;
grid-template-columns: minmax(0, 1fr) 88px 96px;
gap: 10px;
margin-top: 18px;
}
input,
textarea {
width: 100%;
border: 1px solid rgba(72, 48, 19, 0.14);
border-radius: 16px;
background: rgba(255, 255, 255, 0.85);
color: var(--ink);
padding: 14px 16px;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
textarea {
min-height: 260px;
margin-top: 14px;
resize: vertical;
line-height: 1.5;
}
input:focus,
textarea:focus {
border-color: rgba(155, 77, 32, 0.5);
box-shadow: 0 0 0 4px rgba(155, 77, 32, 0.12);
}
button {
border: 0;
border-radius: 16px;
padding: 13px 16px;
cursor: pointer;
color: var(--ink);
background: rgba(92, 70, 33, 0.08);
transition: transform 160ms ease, background 160ms ease, opacity 160ms ease;
}
button:hover {
transform: translateY(-1px);
background: rgba(92, 70, 33, 0.14);
}
button.primary {
color: #fff7ef;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.inventory-preview {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid rgba(72, 48, 19, 0.1);
}
.mini-title {
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--ink);
font-size: 0.92rem;
}
.results {
display: grid;
gap: 14px;
margin-top: 20px;
}
.result-card {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 16px;
padding: 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(255, 250, 241, 0.92), rgba(247, 239, 225, 0.92));
border: 1px solid rgba(72, 48, 19, 0.1);
}
.result-card img,
.result-fallback {
width: 72px;
height: 72px;
border-radius: 18px;
object-fit: cover;
background: linear-gradient(135deg, #d6b58a, #a36b31);
}
.result-fallback {
display: grid;
place-items: center;
color: #fff7ef;
font-weight: 800;
font-size: 1.2rem;
}
.result-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.result-name {
margin: 0;
font-size: 1.1rem;
}
.result-name a {
color: inherit;
text-decoration: none;
}
.result-name a:hover {
text-decoration: underline;
}
.recipe-badge {
padding: 8px 10px;
border-radius: 999px;
background: rgba(47, 122, 66, 0.12);
color: var(--success);
font-size: 0.84rem;
font-weight: 700;
white-space: nowrap;
}
.meta {
margin: 8px 0 0;
color: var(--muted);
font-size: 0.95rem;
}
.ingredients {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.ingredient-pill {
padding: 7px 10px;
border-radius: 999px;
background: rgba(90, 62, 25, 0.08);
font-size: 0.9rem;
}
.empty-state {
padding: 28px 20px;
border-radius: 18px;
text-align: center;
color: var(--muted);
background: rgba(250, 246, 237, 0.72);
border: 1px dashed rgba(72, 48, 19, 0.18);
}
@media (max-width: 920px) {
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.app-shell {
width: min(100vw - 18px, 1180px);
padding-top: 20px;
}
.input-panel,
.output-panel {
padding: 16px;
}
.quick-add {
grid-template-columns: 1fr;
}
.result-card {
grid-template-columns: 1fr;
}
}

File diff suppressed because it is too large Load Diff