Merge pull request 'feat: feedback button, cf-core env-var LLM config, mobile polish' (#14) from feature/orch-auto-lifecycle into main

This commit is contained in:
pyr0ball 2026-04-03 22:01:47 -07:00
commit 63559dcdaa
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)
# 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
USE_GPU=true
GPU_MEMORY_LIMIT=6144
@ -53,3 +70,8 @@ DEMO_MODE=false
# Directus JWT (must match cf-directus SECRET env var)
# 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 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()
@ -10,4 +10,5 @@ api_router.include_router(export.router, tags=["export"
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
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)
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
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<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>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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"
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>
<body>
<div id="app"></div>

View file

@ -113,6 +113,9 @@
</button>
</nav>
<!-- Feedback FAB hidden when FORGEJO_API_TOKEN not configured -->
<FeedbackButton :current-tab="currentTab" />
<!-- Easter egg: Kiwi bird sprite triggered by typing "kiwi" -->
<Transition name="kiwi-fade">
<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 RecipesView from './components/RecipesView.vue'
import SettingsView from './components/SettingsView.vue'
import FeedbackButton from './components/FeedbackButton.vue'
import { useInventoryStore } from './stores/inventory'
import { useEasterEggs } from './composables/useEasterEggs'
@ -220,7 +224,6 @@ body {
min-height: 100vh;
display: flex;
flex-direction: column;
padding-bottom: 68px; /* bottom nav clearance */
}
.sidebar { display: none; }
@ -247,6 +250,8 @@ body {
.app-main {
flex: 1;
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 {
@ -324,7 +329,10 @@ body {
@media (max-width: 480px) {
.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 () => {
await store.fetchItems()
await store.fetchStats()
// Auto-focus scanner gun input
setTimeout(() => {
scannerGunInput.value?.focus()
}, 100)
// Auto-focus scanner gun input desktop only (avoids popping mobile keyboard)
if (!('ontouchstart' in window)) {
setTimeout(() => {
scannerGunInput.value?.focus()
}, 100)
}
})
function onFilterChange() {
@ -762,7 +764,8 @@ function getItemClass(item: InventoryItem): string {
display: flex;
flex-direction: column;
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);
}
/* 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 {
flex-direction: column;
}
@ -1257,6 +1270,31 @@ function getItemClass(item: InventoryItem): string {
.inv-actions {
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) {

View file

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

View file

@ -389,6 +389,8 @@
overflow-x: auto;
padding-bottom: var(--spacing-xs);
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 {

View file

@ -19,10 +19,11 @@ dependencies = [
"numpy>=1.25",
"pyzbar>=0.1.9",
"Pillow>=10.0",
# HTTP client
# HTTP clients
"httpx>=0.27",
"requests>=2.31",
# CircuitForge shared scaffold
"circuitforge-core",
"circuitforge-core>=0.6.0",
]
[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