diff --git a/README.md b/README.md
index 80c8902..443370e 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 8f11008..f7c167c 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -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();
diff --git a/client/src/components/SettingsBar.test.tsx b/client/src/components/SettingsBar.test.tsx
index 808119b..13b831e 100644
--- a/client/src/components/SettingsBar.test.tsx
+++ b/client/src/components/SettingsBar.test.tsx
@@ -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";
@@ -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,
@@ -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();
+
+ 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();
+
+ 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();
+ 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();
+ 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) => void;
+ vi.mocked(fetchPrinterSettings).mockReturnValueOnce(
+ new Promise((r) => {
+ resolve = r;
+ }),
+ );
+ useLabelStore.setState({
+ availablePrinters: twoPrinters,
+ settings: { ...useLabelStore.getState().settings, printerId: "usb:1" },
+ });
+ render();
+ 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();
+ 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();
+
+ 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();
+ 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({
diff --git a/client/src/components/SettingsBar.tsx b/client/src/components/SettingsBar.tsx
index fb88a46..d5b786d 100644
--- a/client/src/components/SettingsBar.tsx
+++ b/client/src/components/SettingsBar.tsx
@@ -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";
@@ -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 {
@@ -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
@@ -84,13 +88,22 @@ export function SettingsBar() {
{!selectedIsVirtual && }
+ {settingsLoading && (
+
+ Loading printer settings…
+
+ )}
+