Compare commits

...

7 commits

Author SHA1 Message Date
63559dcdaa Merge pull request 'feat: feedback button, cf-core env-var LLM config, mobile polish' (#14) from feature/orch-auto-lifecycle into main 2026-04-03 22:01:47 -07:00
c4d0f86366 chore: suppress initial-commit .env false positives in gitleaksignore 2026-04-03 18:57:50 -07:00
f63defa883 fix: mobile overflow — inv-row-right shrink + filter-chip-row width 2026-04-03 18:55:33 -07:00
6791ea22b2 feat: complete feedback button — status probe, requests dep, tests
- feedback.py: add GET /feedback/status endpoint (returns {enabled: bool})
  so frontend can probe on mount instead of optimistic-enable; remove
  unused get_db import
- FeedbackButton.vue: probe /feedback/status on mount, start hidden;
  drop redundant 503-hide path (status probe makes it redundant)
- pyproject.toml: declare requests>=2.31 (used by feedback.py Forgejo calls)
- tests/api/test_feedback.py: 7 tests — status endpoint (no-token, token,
  demo mode), POST 503/403, happy path with mocked Forgejo, 502 on error
2026-04-03 16:52:40 -07:00
504631763b feat: wire cf-core env-var LLM config + coordinator auth (closes #13)
- .env.example: document ANTHROPIC_API_KEY, OPENAI_API_KEY, OLLAMA_HOST,
  OLLAMA_MODEL, CF_ORCH_URL, CF_LICENSE_KEY with usage comments
- config.py: expose CF_LICENSE_KEY in Settings for startup visibility
- pyproject.toml: pin circuitforge-core >= 0.6.0 (env-var auto-config +
  CFOrchClient bearer auth land in 0.6.0)

Bare-metal self-hosters can now run Kiwi with only OLLAMA_HOST set and
zero yaml config. Paid+ users set CF_ORCH_URL + CF_LICENSE_KEY for
managed cloud GPU inference.
2026-04-03 16:48:01 -07:00
61c16af754 feat: in-app feedback FAB + mobile safe-area-inset fixes
- FeedbackButton.vue — floating action button; fires POST /feedback if
  FORGEJO_API_TOKEN is configured, silently hidden otherwise
- feedback.py endpoint — creates Forgejo issues with beta-feedback +
  needs-triage labels; includes version, OS, device info
- App.vue: wire FeedbackButton; use env(safe-area-inset-bottom) for
  bottom-nav clearance (fixes clipped content on iPhone home-bar)
- style.css: overflow-x: hidden prevents stray elements expanding
  mobile viewport
2026-04-03 16:47:50 -07:00
6ff5a5709e fix: mobile nav FOUC + Pantry scan toggle responsive layout
- index.html: inline anti-FOUC CSS so sidebar stays hidden on mobile
  before the JS bundle hydrates (eliminates ~100ms flash of sidebar)
- InventoryList: scan-mode-toggle fills card width on <=480px so buttons
  don't overflow; mode button labels hidden on <=360px (icons only)
- Very narrow phones get icon-only mode toggle, all three buttons still
  accessible via aria-label
2026-04-03 11:10:39 -07:00
13 changed files with 801 additions and 12 deletions

View file

@ -21,6 +21,23 @@ DATA_DIR=./data
# IP this machine advertises to the coordinator (must be reachable from coordinator host) # IP this machine advertises to the coordinator (must be reachable from coordinator host)
# CF_ORCH_ADVERTISE_HOST=10.1.10.71 # CF_ORCH_ADVERTISE_HOST=10.1.10.71
# CF-core hosted coordinator (managed cloud GPU inference — Paid+ tier)
# Set CF_ORCH_URL to use a hosted cf-orch coordinator instead of self-hosting.
# CF_LICENSE_KEY is read automatically by CFOrchClient for bearer auth.
# CF_ORCH_URL=https://orch.circuitforge.tech
# CF_LICENSE_KEY=CFG-KIWI-xxxx-xxxx-xxxx
# LLM backend — env-var auto-config (no llm.yaml needed for bare-metal users)
# LLMRouter checks these in priority order:
# 1. Anthropic cloud — set ANTHROPIC_API_KEY
# 2. OpenAI cloud — set OPENAI_API_KEY
# 3. Local Ollama — set OLLAMA_HOST (+ optionally OLLAMA_MODEL)
# All three are optional; leave unset to rely on a local llm.yaml instead.
# ANTHROPIC_API_KEY=sk-ant-...
# OPENAI_API_KEY=sk-...
# OLLAMA_HOST=http://localhost:11434
# OLLAMA_MODEL=llama3.2
# Processing # Processing
USE_GPU=true USE_GPU=true
GPU_MEMORY_LIMIT=6144 GPU_MEMORY_LIMIT=6144
@ -53,3 +70,8 @@ DEMO_MODE=false
# Directus JWT (must match cf-directus SECRET env var) # Directus JWT (must match cf-directus SECRET env var)
# DIRECTUS_JWT_SECRET= # DIRECTUS_JWT_SECRET=
# In-app feedback → Forgejo issue creation
# FORGEJO_API_TOKEN=
# FORGEJO_REPO=Circuit-Forge/kiwi
# FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1

7
.gitleaksignore Normal file
View file

@ -0,0 +1,7 @@
# Findings suppressed here are historical false positives or already-rotated secrets.
# .env was accidentally included in the initial commit; it is now gitignored.
# Rotate DIRECTUS_JWT_SECRET if it has not been changed since 2026-03-30.
# c166e5216 (chore: initial commit) — .env included by mistake
c166e5216af532a08112ef87e8542cd51c184115:.env:generic-api-key:25
c166e5216af532a08112ef87e8542cd51c184115:.env:cf-generic-env-token:25

View file

@ -0,0 +1,169 @@
"""
Feedback endpoint creates Forgejo issues from in-app feedback.
Ported from peregrine/scripts/feedback_api.py; adapted for Kiwi context.
"""
from __future__ import annotations
import os
import platform
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal
import requests
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.core.config import settings
router = APIRouter()
_ROOT = Path(__file__).resolve().parents[3]
# ── Forgejo helpers ────────────────────────────────────────────────────────────
_LABEL_COLORS = {
"beta-feedback": "#0075ca",
"needs-triage": "#e4e669",
"bug": "#d73a4a",
"feature-request": "#a2eeef",
"question": "#d876e3",
}
def _forgejo_headers() -> dict:
token = os.environ.get("FORGEJO_API_TOKEN", "")
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
def _ensure_labels(label_names: list[str]) -> list[int]:
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/kiwi")
headers = _forgejo_headers()
resp = requests.get(f"{base}/repos/{repo}/labels", headers=headers, timeout=10)
existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
ids: list[int] = []
for name in label_names:
if name in existing:
ids.append(existing[name])
else:
r = requests.post(
f"{base}/repos/{repo}/labels",
headers=headers,
json={"name": name, "color": _LABEL_COLORS.get(name, "#ededed")},
timeout=10,
)
if r.ok:
ids.append(r.json()["id"])
return ids
def _collect_context(tab: str) -> dict:
"""Collect lightweight app context: tab, version, platform, timestamp."""
try:
version = subprocess.check_output(
["git", "describe", "--tags", "--always"],
cwd=_ROOT, text=True, timeout=5,
).strip()
except Exception:
version = "dev"
return {
"tab": tab,
"version": version,
"demo_mode": settings.DEMO_MODE,
"cloud_mode": settings.CLOUD_MODE,
"platform": platform.platform(),
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
def _build_issue_body(form: dict, context: dict) -> str:
_TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
lines: list[str] = [
f"## {_TYPE_LABELS.get(form.get('type', 'other'), '💬 Other')}",
"",
form.get("description", ""),
"",
]
if form.get("type") == "bug" and form.get("repro"):
lines += ["### Reproduction Steps", "", form["repro"], ""]
lines += ["### Context", ""]
for k, v in context.items():
lines.append(f"- **{k}:** {v}")
lines.append("")
if form.get("submitter"):
lines += ["---", f"*Submitted by: {form['submitter']}*"]
return "\n".join(lines)
# ── Schemas ────────────────────────────────────────────────────────────────────
class FeedbackRequest(BaseModel):
title: str
description: str
type: Literal["bug", "feature", "other"] = "other"
repro: str = ""
tab: str = "unknown"
submitter: str = "" # optional "Name <email>" attribution
class FeedbackResponse(BaseModel):
issue_number: int
issue_url: str
# ── Routes ─────────────────────────────────────────────────────────────────────
@router.get("/status")
def feedback_status() -> dict:
"""Return whether feedback submission is configured on this instance."""
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not settings.DEMO_MODE}
@router.post("", response_model=FeedbackResponse)
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
"""
File a Forgejo issue from in-app feedback.
Silently disabled when FORGEJO_API_TOKEN is not set (demo/offline mode).
"""
token = os.environ.get("FORGEJO_API_TOKEN", "")
if not token:
raise HTTPException(
status_code=503,
detail="Feedback disabled: FORGEJO_API_TOKEN not configured.",
)
if settings.DEMO_MODE:
raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")
context = _collect_context(payload.tab)
form = {
"type": payload.type,
"description": payload.description,
"repro": payload.repro,
"submitter": payload.submitter,
}
body = _build_issue_body(form, context)
labels = ["beta-feedback", "needs-triage"]
labels.append({"bug": "bug", "feature": "feature-request"}.get(payload.type, "question"))
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/kiwi")
headers = _forgejo_headers()
label_ids = _ensure_labels(labels)
resp = requests.post(
f"{base}/repos/{repo}/issues",
headers=headers,
json={"title": payload.title, "body": body, "labels": label_ids},
timeout=15,
)
if not resp.ok:
raise HTTPException(status_code=502, detail=f"Forgejo error: {resp.text[:200]}")
data = resp.json()
return FeedbackResponse(issue_number=data["number"], issue_url=data["html_url"])

View file

@ -1,5 +1,5 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback
api_router = APIRouter() api_router = APIRouter()
@ -11,3 +11,4 @@ api_router.include_router(inventory.router, prefix="/inventory", tags=["invento
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"]) api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(staples.router, prefix="/staples", tags=["staples"]) api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])

View file

@ -46,6 +46,10 @@ class Settings:
# CF-core resource coordinator (VRAM lease management) # CF-core resource coordinator (VRAM lease management)
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700") COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
# Hosted cf-orch coordinator — bearer token for managed cloud GPU inference (Paid+)
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
# Feature flags # Feature flags
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes") ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Kiwi — Pantry Tracker</title> <title>Kiwi — Pantry Tracker</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@ -11,6 +11,18 @@
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&display=swap" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<!-- Anti-FOUC: critical layout CSS inline so it's available before the JS bundle.
Without this, the sidebar flashes visible on mobile for ~100ms while the
bundle hydrates and injects component styles. -->
<style>
.sidebar { display: none; }
.bottom-nav { display: flex; }
@media (min-width: 769px) {
.sidebar { display: flex; flex-direction: column; }
.bottom-nav { display: none; }
.app-body { display: flex; flex-direction: column; flex: 1; }
}
</style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -113,6 +113,9 @@
</button> </button>
</nav> </nav>
<!-- Feedback FAB hidden when FORGEJO_API_TOKEN not configured -->
<FeedbackButton :current-tab="currentTab" />
<!-- Easter egg: Kiwi bird sprite triggered by typing "kiwi" --> <!-- Easter egg: Kiwi bird sprite triggered by typing "kiwi" -->
<Transition name="kiwi-fade"> <Transition name="kiwi-fade">
<div v-if="kiwiVisible" class="kiwi-bird-stage" aria-hidden="true"> <div v-if="kiwiVisible" class="kiwi-bird-stage" aria-hidden="true">
@ -153,6 +156,7 @@ import InventoryList from './components/InventoryList.vue'
import ReceiptsView from './components/ReceiptsView.vue' import ReceiptsView from './components/ReceiptsView.vue'
import RecipesView from './components/RecipesView.vue' import RecipesView from './components/RecipesView.vue'
import SettingsView from './components/SettingsView.vue' import SettingsView from './components/SettingsView.vue'
import FeedbackButton from './components/FeedbackButton.vue'
import { useInventoryStore } from './stores/inventory' import { useInventoryStore } from './stores/inventory'
import { useEasterEggs } from './composables/useEasterEggs' import { useEasterEggs } from './composables/useEasterEggs'
@ -220,7 +224,6 @@ body {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-bottom: 68px; /* bottom nav clearance */
} }
.sidebar { display: none; } .sidebar { display: none; }
@ -247,6 +250,8 @@ body {
.app-main { .app-main {
flex: 1; flex: 1;
padding: var(--spacing-md) 0 var(--spacing-xl); padding: var(--spacing-md) 0 var(--spacing-xl);
/* Clear fixed bottom nav — env() gives extra room for iPhone home bar */
padding-bottom: calc(68px + env(safe-area-inset-bottom, 0px));
} }
.container { .container {
@ -324,7 +329,10 @@ body {
@media (max-width: 480px) { @media (max-width: 480px) {
.container { padding: 0 var(--spacing-sm); } .container { padding: 0 var(--spacing-sm); }
.app-main { padding: var(--spacing-sm) 0 var(--spacing-lg); } .app-main {
padding: var(--spacing-sm) 0 var(--spacing-lg);
padding-bottom: calc(68px + env(safe-area-inset-bottom, 0px));
}
} }
/* ============================================ /* ============================================

View file

@ -0,0 +1,413 @@
<template>
<!-- Floating trigger button -->
<button
v-if="enabled"
class="feedback-fab"
@click="open = true"
aria-label="Send feedback or report a bug"
title="Send feedback or report a bug"
>
<svg class="feedback-fab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
</svg>
<span class="feedback-fab-label">Feedback</span>
</button>
<!-- Modal teleported to body to avoid z-index / overflow clipping -->
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="open" class="feedback-overlay" @click.self="close">
<div class="feedback-modal" role="dialog" aria-modal="true" aria-label="Send Feedback">
<!-- Header -->
<div class="feedback-header">
<h2 class="feedback-title">{{ step === 1 ? "What's on your mind?" : "Review & submit" }}</h2>
<button class="feedback-close" @click="close" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<!-- Step 1: Form -->
<div v-if="step === 1" class="feedback-body">
<div class="form-group">
<label class="form-label">Type</label>
<div class="filter-chip-row">
<button
v-for="t in types"
:key="t.value"
:class="['btn-chip', { active: form.type === t.value }]"
@click="form.type = t.value"
type="button"
>{{ t.label }}</button>
</div>
</div>
<div class="form-group">
<label class="form-label">Title <span class="form-required">*</span></label>
<input
v-model="form.title"
class="form-input"
type="text"
placeholder="Short summary of the issue or idea"
maxlength="120"
/>
</div>
<div class="form-group">
<label class="form-label">Description <span class="form-required">*</span></label>
<textarea
v-model="form.description"
class="form-input feedback-textarea"
placeholder="Describe what happened or what you'd like to see…"
rows="4"
/>
</div>
<div v-if="form.type === 'bug'" class="form-group">
<label class="form-label">Reproduction steps</label>
<textarea
v-model="form.repro"
class="form-input feedback-textarea"
placeholder="1. Go to…&#10;2. Tap…&#10;3. See error"
rows="3"
/>
</div>
<p v-if="stepError" class="feedback-error">{{ stepError }}</p>
</div>
<!-- Step 2: Attribution + confirm -->
<div v-if="step === 2" class="feedback-body">
<div class="feedback-summary card">
<div class="feedback-summary-row">
<span class="text-muted text-sm">Type</span>
<span class="text-sm font-semibold">{{ typeLabel }}</span>
</div>
<div class="feedback-summary-row">
<span class="text-muted text-sm">Title</span>
<span class="text-sm">{{ form.title }}</span>
</div>
<div class="feedback-summary-row">
<span class="text-muted text-sm">Description</span>
<span class="text-sm feedback-summary-desc">{{ form.description }}</span>
</div>
</div>
<div class="form-group mt-md">
<label class="form-label">Attribution (optional)</label>
<input
v-model="form.submitter"
class="form-input"
type="text"
placeholder="Your name &lt;email@example.com&gt;"
/>
<p class="text-muted text-xs mt-xs">Include your name and email in the issue if you'd like a response. Never required.</p>
</div>
<p v-if="submitError" class="feedback-error">{{ submitError }}</p>
<div v-if="submitted" class="feedback-success">
Issue filed! <a :href="issueUrl" target="_blank" rel="noopener" class="feedback-link">View on Forgejo </a>
</div>
</div>
<!-- Footer nav -->
<div class="feedback-footer">
<button v-if="step === 2 && !submitted" class="btn btn-ghost" @click="step = 1" :disabled="loading"> Back</button>
<button v-if="!submitted" class="btn btn-ghost" @click="close" :disabled="loading">Cancel</button>
<button
v-if="step === 1"
class="btn btn-primary"
@click="nextStep"
>Next </button>
<button
v-if="step === 2 && !submitted"
class="btn btn-primary"
@click="submit"
:disabled="loading"
>{{ loading ? 'Filing…' : 'Submit' }}</button>
<button v-if="submitted" class="btn btn-primary" @click="close">Done</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
const props = defineProps<{ currentTab?: string }>()
// Probe once on mount hidden until confirmed enabled so button never flashes
const enabled = ref(false)
onMounted(async () => {
try {
const res = await fetch('/api/v1/feedback/status')
if (res.ok) {
const data = await res.json()
enabled.value = data.enabled === true
}
} catch { /* network error — stay hidden */ }
})
const open = ref(false)
const step = ref(1)
const loading = ref(false)
const stepError = ref('')
const submitError = ref('')
const submitted = ref(false)
const issueUrl = ref('')
const types: { value: 'bug' | 'feature' | 'other'; label: string }[] = [
{ value: 'bug', label: '🐛 Bug' },
{ value: 'feature', label: '✨ Feature request' },
{ value: 'other', label: '💬 Other' },
]
const form = ref({
type: 'bug' as 'bug' | 'feature' | 'other',
title: '',
description: '',
repro: '',
submitter: '',
})
const typeLabel = computed(() => types.find(t => t.value === form.value.type)?.label ?? '')
function close() {
open.value = false
// reset after transition
setTimeout(reset, 300)
}
function reset() {
step.value = 1
loading.value = false
stepError.value = ''
submitError.value = ''
submitted.value = false
issueUrl.value = ''
form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' }
}
function nextStep() {
stepError.value = ''
if (!form.value.title.trim() || !form.value.description.trim()) {
stepError.value = 'Please fill in both Title and Description.'
return
}
step.value = 2
}
async function submit() {
loading.value = true
submitError.value = ''
try {
const res = await fetch('/api/v1/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: form.value.title.trim(),
description: form.value.description.trim(),
type: form.value.type,
repro: form.value.repro.trim(),
tab: props.currentTab ?? 'unknown',
submitter: form.value.submitter.trim(),
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
submitError.value = err.detail ?? 'Submission failed.'
return
}
const data = await res.json()
issueUrl.value = data.issue_url
submitted.value = true
} catch (e) {
submitError.value = 'Network error — please try again.'
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* ── Floating action button ─────────────────────────────────────────── */
.feedback-fab {
position: fixed;
right: var(--spacing-md);
bottom: calc(68px + var(--spacing-md)); /* above mobile bottom nav */
z-index: 190;
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: 9px var(--spacing-md);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 999px;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-family: var(--font-body);
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow-md);
transition: background 0.15s, color 0.15s, box-shadow 0.15s, border-color 0.15s;
}
.feedback-fab:hover {
background: var(--color-bg-card);
color: var(--color-text-primary);
border-color: var(--color-border-focus);
box-shadow: var(--shadow-lg);
}
.feedback-fab-icon { width: 15px; height: 15px; flex-shrink: 0; }
.feedback-fab-label { white-space: nowrap; }
/* On desktop, bottom nav is gone — drop to standard corner */
@media (min-width: 769px) {
.feedback-fab {
bottom: var(--spacing-lg);
}
}
/* ── Overlay ──────────────────────────────────────────────────────────── */
.feedback-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0;
}
@media (min-width: 500px) {
.feedback-overlay {
align-items: center;
padding: var(--spacing-md);
}
}
/* ── Modal ────────────────────────────────────────────────────────────── */
.feedback-modal {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
width: 100%;
max-height: 90vh;
overflow-y: auto;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-xl);
}
@media (min-width: 500px) {
.feedback-modal {
border-radius: var(--radius-lg);
width: 100%;
max-width: 520px;
max-height: 85vh;
}
}
.feedback-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.feedback-title {
font-family: var(--font-display);
font-size: var(--font-size-lg);
font-weight: 600;
margin: 0;
}
.feedback-close {
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
}
.feedback-close:hover { color: var(--color-text-primary); }
.feedback-body {
padding: var(--spacing-md);
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.feedback-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.feedback-textarea {
resize: vertical;
min-height: 80px;
font-family: var(--font-body);
font-size: var(--font-size-sm);
}
.form-required { color: var(--color-error); margin-left: 2px; }
.feedback-error {
color: var(--color-error);
font-size: var(--font-size-sm);
margin: 0;
}
.feedback-success {
color: var(--color-success);
font-size: var(--font-size-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-success-bg);
border: 1px solid var(--color-success-border);
border-radius: var(--radius-md);
}
.feedback-link { color: var(--color-success); font-weight: 600; text-decoration: underline; }
/* Summary card (step 2) */
.feedback-summary {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.feedback-summary-row {
display: flex;
gap: var(--spacing-md);
align-items: flex-start;
}
.feedback-summary-row > :first-child { min-width: 72px; flex-shrink: 0; }
.feedback-summary-desc {
white-space: pre-wrap;
word-break: break-word;
}
.mt-md { margin-top: var(--spacing-md); }
.mt-xs { margin-top: var(--spacing-xs); }
/* Transition */
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.2s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
</style>

View file

@ -507,10 +507,12 @@ const manualLoading = ref(false)
onMounted(async () => { onMounted(async () => {
await store.fetchItems() await store.fetchItems()
await store.fetchStats() await store.fetchStats()
// Auto-focus scanner gun input // Auto-focus scanner gun input desktop only (avoids popping mobile keyboard)
setTimeout(() => { if (!('ontouchstart' in window)) {
scannerGunInput.value?.focus() setTimeout(() => {
}, 100) scannerGunInput.value?.focus()
}, 100)
}
}) })
function onFilterChange() { function onFilterChange() {
@ -762,7 +764,8 @@ function getItemClass(item: InventoryItem): string {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-md); gap: var(--spacing-md);
padding: var(--spacing-xs) 0 var(--spacing-xl); padding: var(--spacing-xs) 0 0;
overflow-x: hidden; /* prevent item rows from expanding page width on mobile */
} }
/* ============================================ /* ============================================
@ -1231,6 +1234,16 @@ function getItemClass(item: InventoryItem): string {
gap: var(--spacing-sm); gap: var(--spacing-sm);
} }
/* Mode toggle fills the card width when header stacks */
.scan-mode-toggle {
width: 100%;
}
.scan-mode-btn {
flex: 1;
justify-content: center;
}
.scan-meta-row { .scan-meta-row {
flex-direction: column; flex-direction: column;
} }
@ -1257,6 +1270,31 @@ function getItemClass(item: InventoryItem): string {
.inv-actions { .inv-actions {
gap: 1px; gap: 1px;
} }
/* Prevent right section from blowing out row width on narrow screens */
.inv-row-right {
flex-shrink: 1;
min-width: 0;
gap: var(--spacing-xs);
}
/* Shrink action buttons slightly on mobile */
.inv-row-right .btn-icon {
width: 28px;
height: 28px;
}
}
/* Very narrow phones (360px and below): hide mode button labels, keep icons */
@media (max-width: 360px) {
.scan-mode-btn span {
display: none;
}
.scan-mode-btn svg {
width: 16px;
height: 16px;
}
} }
@media (min-width: 481px) and (max-width: 768px) { @media (min-width: 481px) and (max-width: 768px) {

View file

@ -156,6 +156,7 @@ body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; /* prevent any element from expanding the mobile viewport */
background-color: var(--color-bg-primary); background-color: var(--color-bg-primary);
color: var(--color-text-primary); color: var(--color-text-primary);
} }

View file

@ -389,6 +389,8 @@
overflow-x: auto; overflow-x: auto;
padding-bottom: var(--spacing-xs); padding-bottom: var(--spacing-xs);
scrollbar-width: none; scrollbar-width: none;
min-width: 0; /* allow flex item to shrink below content; lets overflow-x scroll internally */
width: 100%;
} }
.filter-chip-row::-webkit-scrollbar { .filter-chip-row::-webkit-scrollbar {

View file

@ -19,10 +19,11 @@ dependencies = [
"numpy>=1.25", "numpy>=1.25",
"pyzbar>=0.1.9", "pyzbar>=0.1.9",
"Pillow>=10.0", "Pillow>=10.0",
# HTTP client # HTTP clients
"httpx>=0.27", "httpx>=0.27",
"requests>=2.31",
# CircuitForge shared scaffold # CircuitForge shared scaffold
"circuitforge-core", "circuitforge-core>=0.6.0",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

111
tests/api/test_feedback.py Normal file
View file

@ -0,0 +1,111 @@
"""Tests for the /feedback endpoints."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
# ── /feedback/status ──────────────────────────────────────────────────────────
def test_status_disabled_when_no_token(monkeypatch):
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
res = client.get("/api/v1/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": False}
def test_status_enabled_when_token_set(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
res = client.get("/api/v1/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": True}
def test_status_disabled_in_demo_mode(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", True)
res = client.get("/api/v1/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": False}
# ── POST /feedback ────────────────────────────────────────────────────────────
def test_submit_returns_503_when_no_token(monkeypatch):
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
res = client.post("/api/v1/feedback", json={
"title": "Test", "description": "desc", "type": "bug",
})
assert res.status_code == 503
def test_submit_returns_403_in_demo_mode(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", True)
res = client.post("/api/v1/feedback", json={
"title": "Test", "description": "desc", "type": "bug",
})
assert res.status_code == 403
def test_submit_creates_issue(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setenv("FORGEJO_REPO", "Circuit-Forge/kiwi")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
# Mock the two Forgejo HTTP calls: label fetch + issue create
label_response = MagicMock()
label_response.ok = True
label_response.json.return_value = [
{"id": 1, "name": "beta-feedback"},
{"id": 2, "name": "needs-triage"},
{"id": 3, "name": "bug"},
]
issue_response = MagicMock()
issue_response.ok = True
issue_response.json.return_value = {"number": 42, "html_url": "https://example.com/issues/42"}
with patch("app.api.endpoints.feedback.requests.get", return_value=label_response), \
patch("app.api.endpoints.feedback.requests.post", return_value=issue_response):
res = client.post("/api/v1/feedback", json={
"title": "Something broke",
"description": "It broke when I tapped X",
"type": "bug",
"repro": "1. Open app\n2. Tap X",
"tab": "pantry",
})
assert res.status_code == 200
data = res.json()
assert data["issue_number"] == 42
assert data["issue_url"] == "https://example.com/issues/42"
def test_submit_returns_502_on_forgejo_error(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
label_response = MagicMock()
label_response.ok = True
label_response.json.return_value = []
bad_response = MagicMock()
bad_response.ok = False
bad_response.text = "forbidden"
with patch("app.api.endpoints.feedback.requests.get", return_value=label_response), \
patch("app.api.endpoints.feedback.requests.post", return_value=bad_response):
res = client.post("/api/v1/feedback", json={
"title": "Oops", "description": "desc", "type": "other",
})
assert res.status_code == 502