Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cd23fce
feat: persist per-printer label settings (tape size, colors)
szrudi Jun 12, 2026
443d6cb
fix: harden printer_settings against a corrupt "printers" shape
szrudi Jun 12, 2026
dd5626e
fix: persist live store state, not the render closure
szrudi Jun 12, 2026
95d3f3b
docs: correct read_all logging wording; trim README feature line
szrudi Jun 12, 2026
a7aebf8
test: guard state_store as the sole owner of the state file
szrudi Jun 12, 2026
7610f89
fix: reject non-JSON PUT body instead of clearing printer settings
szrudi Jun 12, 2026
4f54e58
fix: read-side robustness for the printer settings store
szrudi Jun 12, 2026
0ea9df5
fix: keep state_store.update best-effort on serialization errors
szrudi Jun 12, 2026
769a1db
fix: warn on an invalid persisted hub/port shape
szrudi Jun 12, 2026
9504acf
chore: trim over-referenced issue #20 mentions to one canonical pointer
szrudi Jun 12, 2026
3d95f52
Merge branch 'main' into feat/persist-printer-settings
szrudi Jun 13, 2026
ee2b650
Merge branch 'main' into feat/persist-printer-settings
szrudi Jun 13, 2026
941b461
feat: gate per-printer settings on load; strip printerId from exports
szrudi Jun 13, 2026
6f760db
fix: reset per-printer fields to defaults for an unconfigured printer
szrudi Jun 13, 2026
bd121da
Merge main into feat/persist-printer-settings
szrudi Jun 13, 2026
29639dc
fix: harden printer settings validation against malformed input
szrudi Jun 13, 2026
a7b2246
refactor: derive pickPersisted from PERSISTED_PRINTER_KEYS
szrudi Jun 13, 2026
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ You can fight against AI usage or learn to embrace it as a new way of working. I
- **Label settings** -- tape size, margins, minimum length, justify, foreground/background colors
- **Per-widget font styles** -- each text widget can have its own font style, scale, frame, and alignment
- **Multi-printer support** -- automatically detects all connected DYMO printers; select specific printer when multiple are available
- **Per-printer settings** -- the tape size and colors loaded in each printer are remembered across visits, so you don't re-pick them every time
- **Virtual printers** -- configure virtual printers that save labels as PNG images, JSON data, or both (great for testing, archiving, and development)
- **Batch print** -- print multiple labels with variable content using `{{varname}}` placeholders, with a table to fill in values per row, configurable copies and pause time, SSE progress streaming, and cancellation support
- **Cut mark** -- optional dotted column painted into the trailing margin between batch labels so you can tear/cut between them; uses the existing inter-label gap, no extra tape
Expand Down
5 changes: 4 additions & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ export default function App() {
setAvailablePrinters(printers);
} catch (error) {
console.error("Failed to load printers:", error);
// Silently fail - UI will work without printer list
// Mark the fetch as settled (with no printers) so the per-printer
// settings gate lifts — otherwise the persisted controls would stay
// disabled forever when /api/printers is unreachable.
setAvailablePrinters([]);
}
};
loadPrinters();
Expand Down
138 changes: 137 additions & 1 deletion client/src/components/SettingsBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ vi.mock("../lib/api", () => ({
}),
powerOn: vi.fn(),
powerOff: vi.fn(),
// usePrinterSettings runs inside SettingsBar.
fetchPrinterSettings: vi.fn().mockResolvedValue({}),
savePrinterSettings: vi.fn().mockResolvedValue(undefined),
}));

import { SettingsBar } from "./SettingsBar";
import { fetchPrinterSettings, savePrinterSettings } from "../lib/api";
import { useLabelStore } from "../state/useLabelStore";
import type { PrinterInfo } from "../types/label";

Expand All @@ -31,9 +35,12 @@ afterEach(() => {
});

beforeEach(() => {
// Reset store to defaults
vi.clearAllMocks();
// Reset store to defaults. availablePrintersLoaded: true so the persisted
// controls aren't gated off in tests that don't exercise the loading gate.
useLabelStore.setState({
availablePrinters: [],
availablePrintersLoaded: true,
settings: {
tapeSizeMm: 12,
marginPx: 56,
Expand Down Expand Up @@ -121,6 +128,135 @@ describe("SettingsBar printer selector", () => {
});
});

describe("SettingsBar per-printer settings persistence", () => {
it("applies saved settings when a printer is selected", async () => {
vi.mocked(fetchPrinterSettings).mockResolvedValueOnce({ tapeSizeMm: 6 });
useLabelStore.setState({
availablePrinters: twoPrinters,
settings: { ...useLabelStore.getState().settings, printerId: "usb:1" },
});
render(<SettingsBar />);

await waitFor(() => {
expect(useLabelStore.getState().settings.tapeSizeMm).toBe(6);
});
expect(fetchPrinterSettings).toHaveBeenCalledWith("usb:1");
});

it("resets persisted fields to defaults for an unconfigured printer", async () => {
// Regression: switching to a printer with no saved settings must reset
// tape/colors to defaults, not inherit the previous printer's values.
vi.mocked(fetchPrinterSettings).mockResolvedValueOnce({});
useLabelStore.setState({
availablePrinters: twoPrinters,
settings: {
...useLabelStore.getState().settings,
printerId: "usb:1",
tapeSizeMm: 19,
foregroundColor: "white",
backgroundColor: "blue",
},
});
render(<SettingsBar />);

await waitFor(() => {
const s = useLabelStore.getState().settings;
expect(s.tapeSizeMm).toBe(12);
expect(s.foregroundColor).toBe("black");
expect(s.backgroundColor).toBe("white");
});
});

it("persists the full subset when the tape size changes", async () => {
useLabelStore.setState({
availablePrinters: twoPrinters,
settings: { ...useLabelStore.getState().settings, printerId: "usb:1" },
});
render(<SettingsBar />);
screen.getByText("Settings").click();

const tapeSelect = screen.getByDisplayValue("12");
// The control is gated until the printer's settings resolve.
await waitFor(() => expect(tapeSelect).not.toBeDisabled());
await userEvent.selectOptions(tapeSelect, "19");

expect(savePrinterSettings).toHaveBeenCalledWith("usb:1", {
tapeSizeMm: 19,
foregroundColor: "black",
backgroundColor: "white",
});
});

it("disables persisted controls until the printer list loads", () => {
useLabelStore.setState({ availablePrinters: [], availablePrintersLoaded: false });
render(<SettingsBar />);
screen.getByText("Settings").click();
expect(screen.getByDisplayValue("12")).toBeDisabled();
});

it("disables then re-enables persisted controls around the settings fetch", async () => {
// A never-resolving fetch keeps the control gated.
let resolve!: (v: Record<string, never>) => void;
vi.mocked(fetchPrinterSettings).mockReturnValueOnce(
new Promise((r) => {
resolve = r;
}),
);
useLabelStore.setState({
availablePrinters: twoPrinters,
settings: { ...useLabelStore.getState().settings, printerId: "usb:1" },
});
render(<SettingsBar />);
screen.getByText("Settings").click();

const tapeSelect = screen.getByDisplayValue("12");
await waitFor(() => expect(tapeSelect).toBeDisabled());
resolve({});
await waitFor(() => expect(tapeSelect).not.toBeDisabled());
});

it("re-enables controls (graceful unlock) when the settings fetch errors", async () => {
vi.mocked(fetchPrinterSettings).mockRejectedValueOnce(new Error("Pi down"));
useLabelStore.setState({
availablePrinters: twoPrinters,
settings: { ...useLabelStore.getState().settings, printerId: "usb:1" },
});
render(<SettingsBar />);
screen.getByText("Settings").click();

const tapeSelect = screen.getByDisplayValue("12");
await waitFor(() => expect(tapeSelect).not.toBeDisabled());
});

it("uses the single connected printer even on Auto-select", async () => {
useLabelStore.setState({
availablePrinters: [twoPrinters[0]!],
settings: { ...useLabelStore.getState().settings, printerId: undefined },
});
render(<SettingsBar />);

await waitFor(() => {
expect(fetchPrinterSettings).toHaveBeenCalledWith("usb:1");
});
});

it("does not persist on Auto-select with multiple printers", async () => {
useLabelStore.setState({
availablePrinters: twoPrinters,
settings: { ...useLabelStore.getState().settings, printerId: undefined },
});
render(<SettingsBar />);
screen.getByText("Settings").click();

const tapeSelect = screen.getByDisplayValue("12");
await userEvent.selectOptions(tapeSelect, "19");

expect(savePrinterSettings).not.toHaveBeenCalled();
// Ambiguous which printer the server picks, so we don't read either.
expect(fetchPrinterSettings).not.toHaveBeenCalled();
});
});

describe("SettingsBar power toggle visibility", () => {
it("shows the power toggle when a real USB printer is selected", async () => {
useLabelStore.setState({
Expand Down
51 changes: 35 additions & 16 deletions client/src/components/SettingsBar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useLabelStore } from "../state/useLabelStore";
import { usePrinterSettings } from "../state/usePrinterSettings";
import { TAPE_SIZES, LABEL_COLORS } from "../lib/constants";
import { fetchPrinters } from "../lib/api";
import type { TapeSize, Alignment, LabelColor } from "../types/label";
Expand All @@ -12,6 +13,12 @@ export function SettingsBar() {
const setAvailablePrinters = useLabelStore((s) => s.setAvailablePrinters);
const [isRefreshing, setIsRefreshing] = useState(false);

// Per-printer settings persistence: applies saved tape/color for the
// selected printer, and `persist` saves user changes to those. `loading`
// is true until those settings resolve — we disable the persisted controls
// until then so a late-arriving fetch can't clobber an edit.
const { persist, loading: settingsLoading } = usePrinterSettings();

const handleRefreshPrinters = async () => {
setIsRefreshing(true);
try {
Expand All @@ -24,13 +31,10 @@ export function SettingsBar() {
}
};

// Only show printer selector if multiple printers are detected
// TODO: Future improvements for multi-printer UI:
// - Show printer status indicators (online/offline)
// - Display tape type/color/width for each printer
// - Allow users to set friendly aliases for printers
// - Remember last selected printer per user
// - Support printer-specific preset configurations
// Only show printer selector if multiple printers are detected.
// Tape size + colors now persist per printer; the rest of
// the multi-printer UI cluster (status indicators, capability-aware tape
// display, aliases, presets) is tracked in #38.
const showPrinterSelector = availablePrinters.length > 1;

// The selection shown in the dropdown. Fall back to the first printer
Expand Down Expand Up @@ -84,13 +88,22 @@ export function SettingsBar() {

{!selectedIsVirtual && <PowerToggle />}

{settingsLoading && (
<span className="text-xs text-gray-500 italic">
Loading printer settings…
</span>
)}

<Field label="Tape (mm)">
<select
className="input w-20"
value={settings.tapeSizeMm}
onChange={(e) =>
update({ tapeSizeMm: Number(e.target.value) as TapeSize })
}
disabled={settingsLoading}
onChange={(e) => {
const tapeSizeMm = Number(e.target.value) as TapeSize;
update({ tapeSizeMm });
persist({ tapeSizeMm });
}}
>
{TAPE_SIZES.map((s) => (
<option key={s} value={s}>
Expand Down Expand Up @@ -138,9 +151,12 @@ export function SettingsBar() {
<select
className="input w-24"
value={settings.foregroundColor}
onChange={(e) =>
update({ foregroundColor: e.target.value as LabelColor })
}
disabled={settingsLoading}
onChange={(e) => {
const foregroundColor = e.target.value as LabelColor;
update({ foregroundColor });
persist({ foregroundColor });
}}
>
{LABEL_COLORS.map((c) => (
<option key={c} value={c}>
Expand All @@ -154,9 +170,12 @@ export function SettingsBar() {
<select
className="input w-24"
value={settings.backgroundColor}
onChange={(e) =>
update({ backgroundColor: e.target.value as LabelColor })
}
disabled={settingsLoading}
onChange={(e) => {
const backgroundColor = e.target.value as LabelColor;
update({ backgroundColor });
persist({ backgroundColor });
}}
>
{LABEL_COLORS.map((c) => (
<option key={c} value={c}>
Expand Down
63 changes: 63 additions & 0 deletions client/src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
printLabel,
fetchServerPreview,
uploadImage,
fetchPrinterSettings,
savePrinterSettings,
} from "./api";
import type { LabelWidget, LabelSettings } from "../types/label";

Expand Down Expand Up @@ -255,3 +257,64 @@ describe("uploadImage", () => {
await expect(uploadImage(file)).rejects.toThrow("No file provided");
});
});

describe("fetchPrinterSettings", () => {
it("GETs the URL-encoded printer id and returns the settings", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ settings: { tapeSizeMm: 19 } }),
});

const result = await fetchPrinterSettings("Bus 001 Device 005: ID 0922:1234");

expect(result).toEqual({ tapeSizeMm: 19 });
expect(mockFetch).toHaveBeenCalledWith(
"/api/printers/Bus%20001%20Device%20005%3A%20ID%200922%3A1234/settings",
);
});

it("returns {} when the server reports no saved settings", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ settings: {} }),
});
expect(await fetchPrinterSettings("usb:1")).toEqual({});
});

it("throws on a non-ok response", async () => {
mockFetch.mockResolvedValueOnce({ ok: false });
await expect(fetchPrinterSettings("usb:1")).rejects.toThrow();
});
});

describe("savePrinterSettings", () => {
it("PUTs the settings as JSON to the encoded id", async () => {
mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) });

await savePrinterSettings("virtual:Office", {
tapeSizeMm: 12,
foregroundColor: "black",
backgroundColor: "white",
});

expect(mockFetch).toHaveBeenCalledWith(
"/api/printers/virtual%3AOffice/settings",
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tapeSizeMm: 12,
foregroundColor: "black",
backgroundColor: "white",
}),
},
);
});

it("throws on a non-ok response", async () => {
mockFetch.mockResolvedValueOnce({ ok: false });
await expect(
savePrinterSettings("usb:1", { tapeSizeMm: 12 }),
).rejects.toThrow();
});
});
30 changes: 30 additions & 0 deletions client/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
LabelSettings,
PrinterInfo,
PowerStatus,
PersistedPrinterSettings,
} from "../types/label";

interface PrintResponse {
Expand Down Expand Up @@ -189,3 +190,32 @@ export async function powerOff(): Promise<PowerStatus> {
const res = await fetch("/api/power/off", { method: "POST" });
return _readPowerResponse(res);
}

// Per-printer label settings: the long-lived "what tape/colors
// are loaded" subset, persisted server-side keyed by printer id. Printer
// ids contain spaces and colons, so encode the whole segment.
export async function fetchPrinterSettings(
printerId: string,
): Promise<PersistedPrinterSettings> {
const res = await fetch(
`/api/printers/${encodeURIComponent(printerId)}/settings`,
);
if (!res.ok) throw new Error("Failed to fetch printer settings");
const data = (await res.json()) as { settings?: PersistedPrinterSettings };
return data.settings ?? {};
}

export async function savePrinterSettings(
printerId: string,
settings: PersistedPrinterSettings,
): Promise<void> {
const res = await fetch(
`/api/printers/${encodeURIComponent(printerId)}/settings`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
},
);
if (!res.ok) throw new Error("Failed to save printer settings");
}
Loading
Loading