Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions .claude/commands/release-prep.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,27 @@ In `README.md`:

1. Update the version badge: in `**Version:** <current>`, replace the current
version with `$ARGUMENTS` (e.g. `**Version:** 0.0.1` → `**Version:** $ARGUMENTS`).
2. Add a `### v$ARGUMENTS (<today>)` entry at the top of the `## What's New`
section, summarising this release in user-facing language drawn from the
changelog entries you just rolled. Keep it consistent with the voice of the
existing entries.
2. Add an entry at the top of the `## What's New` section using the
**tiered format** based on whether this is a feature or patch release:

- **Feature release** (PATCH == 0, e.g. `0.2.0`): add a full **overview**
entry — a short paragraph covering the headline features, consistent with
the voice of the existing `v0.1.0` entry. Heading:
`### v$ARGUMENTS (<today>)`

- **Patch release** (PATCH > 0, e.g. `0.1.3`): add a **compact** entry.
Heading line:
`### v$ARGUMENTS (<today>) — [What's New](CHANGELOG.md#<anchor>)`
followed by a single line of user-facing prose summarising the release.

Compute `<anchor>` using GitHub's heading-slug rule: lowercase the
changelog section heading, remove every character that is not
alphanumeric, a space, or a hyphen (this strips `.` `[` `]` `—` and
other punctuation), then replace each remaining space with a hyphen.
Two consecutive spaces (left by removing the em-dash and its surrounding
spaces) produce a double-hyphen. Example: `## [0.1.2] — 2026-06-07`
→ remove `[`, `]`, `.`, `—` → `012 20260607` (two spaces where the
em-dash was) → lowercase + spaces→hyphens → `#012--2026-06-07`.
3. Update any top-of-file new-in banner / one-line status blurb to reference
`$ARGUMENTS` if it currently names a specific version.

Expand Down
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ data-dev/
*.sqlite
*.sqlite3

# Leftover vexp index dir — vexp is de-adopted (see standards.md), but its host
# daemon keeps regenerating .vexp/ until host teardown lands in the ansible repo.
# Ignored only to prevent accidentally committing regenerated index state.
# vexp is fully de-adopted (see standards.md) and the host daemon was torn down
# via the ansible repo — nothing regenerates this anymore. Kept ignored as a
# defensive guard against accidentally committing any stray index state.
.vexp/

# Logs
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to labelforge are recorded here. Format follows [Keep a Chan

## [Unreleased]

## [0.1.3] — 2026-06-07

### Changed

- README "What's New" now uses a tiered format — feature releases keep a full overview entry, patch releases get a one-line summary linking to the changelog; `/release-prep` Step 4 updated to match.

### Added

- feat: label pickers default to the last label you used (remembered across sessions via localStorage); applies to Quick Print, New Template, and Save As.
- feat: app version shown in a fixed footer on every page, linking to its GitHub release notes; shows an "Update available" indicator and a one-time release-notes popup when a newer release is detected (toggle in Settings → Updates, on by default; backend-proxied with a 6-hour cache so the browser never contacts GitHub directly).
- feat: dev/unreleased builds now show a `-dev+<sha>` suffix in the version footer (e.g. `v0.1.2-dev+8e32bb1`) and never show the "Update available" nag; release builds remain plain `v0.1.2`.

### Fixed

- fix: render templates at the correct position when elements use centered origins (`originX: 'center'` / `originY: 'center'`); previously such elements were shifted right and down by half their box size, fanning wider elements further than narrow ones.
- fix: editor canvas now shows the selected font instead of a serif fallback; server fonts are loaded into the browser via the new `GET /api/fonts/{name}/file` endpoint and registered with the FontFace API on startup.

## [0.1.2] — 2026-06-07

### Fixed
Expand Down
84 changes: 58 additions & 26 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Owner: crzykidd. Personal homelab project, public open source. Single-user app

## Build Status

- **Last shipped:** v0.1.2
- **Last shipped:** v0.1.3
- **Target for next release:** TBD

## Standards
Expand Down Expand Up @@ -64,31 +64,14 @@ From the session prompt that owns this project:

## Session workflow

Every task follows: **plan → decide → execute → document**.

1. **Plan first.** Before writing code, outline what will change and why. For small fixes the plan can be verbal in-session. For larger features, produce a handoff prompt (see below).
2. **Decide: current session or handoff.** If the plan is scoped and the current session has context, do it here. If it's a large feature slice or a fresh context would be cleaner, write a handoff prompt for a new session.
3. **Handoff prompts live in `prompts/`** (checked into git), per the `handoff-prompt-workflow` standard pinned in [`standards.md`](standards.md). The top-level `prompts/` dir is the **live queue** — pending work only. Start new prompts from `prompts/TEMPLATE.md`. Frontmatter:
```yaml
---
name: YYYY-MM-DD-short-description
status: pending # pending | completed | failed
created: YYYY-MM-DD
model: # opus = research/planning, sonnet = coding
completed: # filled when done
result: # one-line summary of outcome
---
```
Before making edits, run `git status --porcelain` and cross-reference the files the plan touches; if any overlap with uncommitted work, list them and ask before touching. The **last instruction** in every handoff prompt: update its own frontmatter (status / completed / result), then `git mv` it into `prompts/done/` (success) or `prompts/failed/` (failure) — created lazily on first use. Record non-obvious decisions (approach changed, alternative rejected, workaround needed) in `docs/decisions.md` as an ADR entry.
4. **To run a handoff prompt** — the moment you create one, hand the user this exact command (file-path form, never inlined `cat`):
```
claude --model <model> "Read prompts/<file>.md and execute it as your task."
```
`<model>` matches the prompt's `model:` field (opus = research/planning, sonnet = coding; omit to use the default). Run from the repo root so the relative path resolves.
5. **Changelog entry required.** Every change — feature, fix, refactor — gets a short entry in `CHANGELOG.md` under `## [Unreleased]`. Write it for release notes (concise, user-facing language).
6. **All dev work on `dev`** unless explicitly told otherwise.
7. **Commit, don't push.** Sessions commit their work with a descriptive message. The owner pushes.
8. **Planning prompts for large features.** The owner will ask for a planning session prompt when scoping a new feature block. That prompt gets handed to a fresh session to execute.
Every task follows: **plan → decide → execute → document**. For handoff prompt mechanics
(edit-size threshold, spawning agents, working-tree check, commit flow) see the
**Handoff prompts (operational rules)** section at the bottom of this file.

1. **Changelog entry required.** Every change — feature, fix, refactor — gets a short entry in `CHANGELOG.md` under `## [Unreleased]`. Write it for release notes (concise, user-facing language).
2. **All dev work on `dev`** unless explicitly told otherwise.
3. **Commit, don't push.** Sessions commit their work with a descriptive message. The owner pushes.
4. **Planning prompts for large features.** The owner will ask for a planning session prompt when scoping a new feature block. That prompt gets handed to a fresh session to execute.

## Repo conventions

Expand All @@ -108,6 +91,55 @@ Every task follows: **plan → decide → execute → document**.
- Don't write giant explainer comments in code — code should be readable; comments only for non-obvious *why*
- Don't generate `package.json` / `pyproject.toml` / `Dockerfile` until the relevant slice has been scoped

<!--
Source: standards/handoff-prompt-workflow @ v2.0.0 (crzynet/homelab-configs).
Paste the section below verbatim into the adopting project's CLAUDE.md.
The full standard (the plan→decide→execute→document principle, model selection,
TEMPLATE, adoption checklist) lives at:
https://gitea.crzynet.com/crzynet/homelab-configs/src/branch/main/standards/handoff-prompt-workflow/README.md
-->

## Handoff prompts (operational rules)

This project adopts the `handoff-prompt-workflow` standard. The full why-and-how lives at
the source above; the rules below are the per-session do/don'ts an agent must honor by
default:

- **Edit-size threshold — decide by how much you'll change:**
- A genuinely small change — roughly **one or two files and a few lines** (a typo, one
config value, a one-line fix) — do it **in-session**, no prompt.
- **Anything bigger requires a handoff prompt** — more than ~2 files, a multi-step
change, a new feature, or any edit large enough that a fresh context would run it
more cleanly. **When in doubt, write the prompt.**
- **A handoff prompt is a file in `prompts/`** — one per task, from `prompts/TEMPLATE.md`,
with frontmatter (`name`, `status`, `created`, `model`, `completed`, `result`). Set
`model:` from the task type: **Opus** for research/planning, **Sonnet** for coding;
mixed defaults to Opus.
- **Execute the prompt by spawning a subagent — don't hand the user a command.** Spawn an
agent on the prompt's `model:`, let it run the prompt end-to-end, and **report the
outcome back**. The agent gets a fresh context; you stay in the loop.
- **Manual fallback only on explicit request.** If the user says e.g. "use manual
prompts for this," give them
`claude --model <model> "Read prompts/<file>.md and execute it as your task."`
instead of spawning.
- **Check the working tree before editing.** Run `git status --porcelain`, cross-reference
the files the plan touches; if any have uncommitted changes, list them and ask before
touching. Surface unrelated dirty files once; they don't block.
- **The prompt self-updates and moves when done.** The executing agent sets its
frontmatter (`status`/`completed`/`result`) and `git mv`s the file into `prompts/done/`
(success) or `prompts/failed/` (failure).
- **One commit at the end; the prompt bundles in.** The prompt file is **not** committed
up front — it lands in the single end commit alongside the work and the prompt move.
Propose ONE commit (files list + one-line message), ask `y/n`, stage only those specific
paths. **Never `git add -A`, never auto-commit, never push.** A spawned agent prepares
the tree and reports the proposed commit back; the orchestrating session surfaces the
`y/n`.
- **Record non-obvious decisions** (approach changes, rejected alternatives, workarounds)
in `docs/decisions.md`, newest at top.

If you're unsure whether an action would violate one of the above, stop and ask before
acting.

<!--
Source: standards/code-checkin-and-pr @ v1.1.0 (crzynet/homelab-configs).
Paste the section below verbatim into the adopting project's CLAUDE.md.
Expand Down
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,12 @@ USER labelforge

EXPOSE 8000

# Build-time channel + commit markers. Placed last to avoid busting earlier
# cache layers on every commit. Stamp with:
# docker build --build-arg BUILD_CHANNEL=dev --build-arg GIT_COMMIT=$(git rev-parse --short HEAD) .
ARG BUILD_CHANNEL=release
ARG GIT_COMMIT=""
ENV LABELFORGE_CHANNEL=$BUILD_CHANNEL \
LABELFORGE_COMMIT=$GIT_COMMIT

CMD ["uvicorn", "labelforge.main:app", "--host", "0.0.0.0", "--port", "8000"]
25 changes: 8 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,20 @@

Self-hosted web app for designing, saving, and printing labels to Brother QL series printers.

**Status**: Released (v0.1.2) — all v1 features are working and the app is packaged as a single Docker image.
**Status**: Released (v0.1.3) — all v1 features are working and the app is packaged as a single Docker image.

**Version:** 0.1.2
**Version:** 0.1.3

## What's New

### v0.1.2 (2026-06-07)
### v0.1.3 (2026-06-07) — [What's New](CHANGELOG.md#013--2026-06-07)
App version now appears on every page linking to its GitHub release notes, with an update-available indicator and a one-time release-notes popup; adds last-used media defaults and fixes centered-element rendering and editor fonts.

Fixes the published container image. Builds were producing an OCI index with provenance/SBOM
attestation child manifests; the weekly image cleanup deleted those untagged children, leaving
`:latest`/`:v0.1.1` pointing at a missing manifest (`docker pull` → 404). The image is now a
plain single-platform manifest and the cleanup keeps a safety buffer, so pulls work again.
### v0.1.2 (2026-06-07) — [What's New](CHANGELOG.md#012--2026-06-07)
Fixes the published container image so `docker pull` of `:latest` works again.

### v0.1.1 (2026-06-06)

Deployment reliability. Startup now logs in detail (version, effective config, data directory,
database created/opened, migrations, "startup complete") and **fails fast with a clear message**
instead of crashing silently — a missing `PRINTER_HOST`/`API_TOKEN` or an unwritable `DATA_DIR`
(the container runs as uid 1000) is now reported as a `CRITICAL` log line with the fix. The
image sets `PYTHONUNBUFFERED=1` and creates/owns `/data` so named-volume deploys work out of the
box. Also rolls in the pending dependency updates (fastapi, pydantic, fabric 7, vite 8,
typescript 6, Docker base `python:3.14-slim`, CI actions). See the permissions notes under
**Running it**.
### v0.1.1 (2026-06-06) — [What's New](CHANGELOG.md#011--2026-06-06)
Deployment reliability: fail-fast startup logging, correct `DATA_DIR` permissions, and rolled-in dependency updates.

### v0.1.0 (2026-06-06)

Expand Down
11 changes: 10 additions & 1 deletion backend/labelforge/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ def configure_logging() -> None:
except PackageNotFoundError: # running from a source tree, not pip-installed
__version__ = "unknown"

# Build markers baked in at image build time via Docker build args.
# The container has no .git, so runtime detection is not possible.
__channel__ = os.environ.get("LABELFORGE_CHANNEL", "release").strip() or "release"
__commit__: str | None = os.environ.get("LABELFORGE_COMMIT", "").strip() or None

logging.getLogger("labelforge").info(
"labelforge %s — process starting (python %s)", __version__, sys.version.split()[0]
"labelforge %s — process starting (python %s) channel=%s commit=%s",
__version__,
sys.version.split()[0],
__channel__,
__commit__ or "unknown",
)
2 changes: 2 additions & 0 deletions backend/labelforge/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from labelforge.routes import settings as settings_router
from labelforge.routes import template_print as template_print_router
from labelforge.routes import templates as templates_router
from labelforge.routes import version as version_router

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -146,6 +147,7 @@ async def _retention_loop() -> None:
app.include_router(templates_router.router, prefix="/api")
app.include_router(template_print_router.router, prefix="/api")
app.include_router(history_router.router, prefix="/api")
app.include_router(version_router.router, prefix="/api")

_FRONTEND_DIST = Path("/app/frontend/dist")

Expand Down
29 changes: 27 additions & 2 deletions backend/labelforge/render/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@
}


def _origin_top_left(obj: dict, left: int, top: int, box_w: int, box_h: int) -> tuple[int, int]:
"""Translate Fabric left/top (origin-relative) to the top-left corner.

Fabric stores left/top relative to originX/originY. The renderer pastes at the
top-left, so shift by half/full box for center/right (x) and center/bottom (y).
Defaults (left/top) are a no-op.
"""
ox = str(obj.get("originX", "left")).lower()
oy = str(obj.get("originY", "top")).lower()
if ox == "center":
left -= box_w // 2
elif ox == "right":
left -= box_w
if oy == "center":
top -= box_h // 2
elif oy == "bottom":
top -= box_h
return left, top


def _canvas_color_to_l(color: str | None) -> int | None:
"""Map a CSS color string to mode-L pixel value; None means no fill."""
if not color or color in ("transparent", "rgba(0,0,0,0)", "none"):
Expand Down Expand Up @@ -215,8 +235,10 @@ def detect_overflow(template: Template, media_id: str) -> bool:
return False
max_h = label.dots_printable[1]
for obj in template.canvas_json.get("objects", []):
top = int(obj.get("top", 0))
raw_top = int(obj.get("top", 0))
box_w = int(obj.get("width", 0) * float(obj.get("scaleX", 1.0)))
h = int(obj.get("height", 0) * float(obj.get("scaleY", 1.0)))
_, top = _origin_top_left(obj, 0, raw_top, box_w, h)
if top + h > max_h:
return True
return False
Expand Down Expand Up @@ -268,13 +290,15 @@ def render_template(
if is_continuous:
bottommost = 0
for i, obj in enumerate(objects):
t = int(obj.get("top", 0))
raw_top = int(obj.get("top", 0))
box_w = max(1, int(obj.get("width", 10) * float(obj.get("scaleX", 1.0))))
# Text: use PIL-measured height; other elements: Fabric height is reliable.
h = (
text_subs[i].height
if i in text_subs
else int(obj.get("height", 0) * float(obj.get("scaleY", 1.0)))
)
_, t = _origin_top_left(obj, 0, raw_top, box_w, h)
bottommost = max(bottommost, t + h)
canvas_h = max(bottommost + _PADDING, 1)
else:
Expand All @@ -296,6 +320,7 @@ def render_template(
angle = float(obj.get("angle", 0))
box_w = max(1, int(obj.get("width", 10) * float(obj.get("scaleX", 1.0))))
box_h = max(1, int(obj.get("height", 10) * float(obj.get("scaleY", 1.0))))
left, top = _origin_top_left(obj, left, top, box_w, box_h)

try:
if norm_type in ("itext", "text", "textbox"):
Expand Down
33 changes: 31 additions & 2 deletions backend/labelforge/routes/fonts.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,41 @@
from fastapi import APIRouter, Depends
from pathlib import Path

from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse

from labelforge.models import FontInfo
from labelforge.render.fonts import get_fonts
from labelforge.render.fonts import get_font_path, get_fonts
from labelforge.routes.auth import require_auth

router = APIRouter(dependencies=[Depends(require_auth)])

_MEDIA_TYPES: dict[str, str] = {
".ttf": "font/ttf",
".otf": "font/otf",
}


@router.get("/fonts", response_model=list[FontInfo])
async def list_fonts() -> list[FontInfo]:
return [FontInfo(name=f.name, path=f.path, family=f.family, style=f.style) for f in get_fonts()]


@router.get("/fonts/{name}/file")
async def get_font_file(name: str) -> FileResponse:
"""Serve the raw font bytes for a font known to the server.

The path is resolved exclusively via get_font_path() — the scanner's
allow-list — so user input is never joined onto a directory. Unknown
names and path-traversal attempts both 404.
"""
path = get_font_path(name)
if path is None:
raise HTTPException(status_code=404, detail=f"Font not found: {name}")
font_path = Path(path)
suffix = font_path.suffix.lower()
media_type = _MEDIA_TYPES.get(suffix, "application/octet-stream")
return FileResponse(
path=font_path,
media_type=media_type,
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
Loading