diff --git a/.impeccable/critique/2026-08-31T15-59-52Z__src-ui-py.md b/.impeccable/critique/2026-08-31T15-59-52Z__src-ui-py.md deleted file mode 100644 index 6addf3d..0000000 --- a/.impeccable/critique/2026-08-31T15-59-52Z__src-ui-py.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -target: our frontend -total_score: 24 -max_score: 40 -na_heuristics: -p0_count: 0 -p1_count: 2 -timestamp: 2026-08-31T15-59-52Z -slug: src-ui-py ---- -# ShiftPress Frontend Critique - -Method: dual-agent (A: frontend_design_assessment · B: frontend_detector_assessment) - -## Design Health Score - -| # | Heuristic | Score | Key issue | -|---|---|---:|---| -| 1 | Visibility of System Status | 2 | Progress advances before Word finishes, and partial failure leaves a green completion state. | -| 2 | Match System / Real World | 3 | Shift, date, printer, and document language fits the operator workflow; some failure copy remains implementation-oriented. | -| 3 | User Control and Freedom | 3 | Independent selection and cancellation are strong; Setup has no revert path and the active Word call cannot be interrupted. | -| 4 | Consistency and Standards | 3 | Mirrored native controls are predictable; semantic colors occasionally leak across roles. | -| 5 | Error Prevention | 2 | Pick-only dates and preflight help, but locally known invalid runs remain actionable. | -| 6 | Recognition Rather Than Recall | 3 | Scope and printer remain visible; Setup does not identify the missing folder or currently required configuration. | -| 7 | Flexibility and Efficiency | 2 | Smart defaults and batching help, but there are few accelerators and distant dates require calendar navigation. | -| 8 | Aesthetic and Minimalist Design | 3 | The sequence and palette are disciplined, with minor action and status noise. | -| 9 | Error Recovery | 2 | Work is preserved and reports exist, but modal errors are remote from their source and footer state can contradict them. | -| 10 | Help and Documentation | 1 | Hover-only tooltips are the only contextual help. | -| **Total** | | **24/40** | **Acceptable; significant trust-state improvements needed** | - -## Design Specificity Verdict - -ShiftPress feels authored for this product in structure and interaction. The mirrored Night/Day panels, today/next-day defaults, independent scopes, shift colors, concrete manifest, and count-bearing print action belong to the schedule handoff rather than a generic form. - -The deterministic detector returned an empty JSON array with exit code 0 and no rule locations. This is not proof of native UI quality: the detector is web-oriented and has no Tkinter-specific rules. Browser overlays were inapplicable because `src/ui.py` is not browser-renderable, and `docs/screenshots/main.png` is absent, so no runtime visual inspection was available. - -## Overall Impression - -The selection model is clear, low-risk, and unusually specific to the operator's real job. The largest opportunity is to make readiness and completion as truthful as selection: green state, 100% progress, and successful completion currently appear before the system has earned them. - -## What's Working - -1. Independent Night and Day intent is exceptionally legible through symmetric controls, separate dates, and separate counts. -2. The manifest translates settings into paper consequences with numbered scopes, document counts, and the target printer. -3. Setup disclosure and smart Night-today/Day-tomorrow defaults keep repeat use fast without hiding the print scope. - -## Priority Issues - -### [P1] Progress and completion can state something untrue - -Progress is advanced before each Word call returns, and `Complete!` is applied before failures are evaluated. Show the active item separately, advance completed progress only after Word returns, and finish in distinct success, partial-failure, and cancellation states. - -Suggested command: `$impeccable harden` - -### [P1] Known-invalid runs remain actionable and readiness is implied too early - -Empty selection, invalid ranges, missing printer, and missing folders do not disable Print. Green `Selected` state appears before preflight. Disable locally invalid runs, name the blocker inline, keep selection neutral until preflight, and show an explicit checking state. - -Suggested command: `$impeccable harden` - -### [P2] Setup and printer status are ambiguous or stale - -`Templates configured` means only that both strings are non-empty, and the no-printer message is not refreshed when availability changes. Summarize Day, Night, and printer separately, scope warnings to enabled shifts, and make availability status dynamic. - -Suggested command: `$impeccable clarify` - -### [P2] Keyboard and accessibility states are not explicitly protected - -Custom dark styles have no tested focus-visible contract, tooltips are hover-only, and Setup focuses the window instead of the first control. Add explicit focus maps, useful focus placement, Escape behavior, access keys, and text alternatives for tooltip-only information. - -Suggested command: `$impeccable audit` - -### [P2] Long and degraded states are insufficiently hardened - -Printer and manifest values do not have a clear wrapping strategy. If `tkcalendar` is missing, the shift panels are absent but the action area can remain interactive. Wrap long workstation values and replace the action area with a disabled dependency-error state when date controls cannot load. - -Suggested command: `$impeccable harden` - -## Persona Red Flags - -**Alex, power user:** The default run is fast, but no global accelerator or mnemonics exist, known dates cannot be typed, and Setup has no keyboard-first focus target. - -**Jordan, first-timer:** Green `Selected` plus `Templates configured` reads as permission to print, yet the first authoritative correction may be a modal after clicking Print. No visible help explains template naming or what configured guarantees. - -**Sam, keyboard/low-vision user:** DateEntry has keyboard affordances and state is not color-only, but focus visibility depends on theme defaults, hover tooltips are inaccessible, and dynamic status is not explicitly announced. - -## Minor Observations - -- The blue primary action has a Day-amber border, weakening its shift-neutral meaning. -- Body/control text uses the Display face rather than reserving it for headings. -- `1 operation(s) failed` should be properly pluralized operator language such as `1 schedule failed`. -- The empty progress bar and 0% can remain hidden until validation begins. -- PRODUCT.md references `docs/screenshots/main.png`, but that file does not exist. - -## Questions to Consider - -- Does green mean selected or verified ready? -- Is a job complete when Word begins, when Word returns, or when the spooler accepts it? -- Should Setup describe the whole installation or only what the enabled shifts require? -- Would strict typed dates make distant repeat work faster without weakening safety? diff --git a/.impeccable/critique/2026-08-31T17-06-34Z__src-ui-py.md b/.impeccable/critique/2026-08-31T17-06-34Z__src-ui-py.md deleted file mode 100644 index 48686f3..0000000 --- a/.impeccable/critique/2026-08-31T17-06-34Z__src-ui-py.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -target: frontend and codebase after production-trust polish -total_score: 40 -max_score: 40 -na_heuristics: -p0_count: 0 -p1_count: 0 -timestamp: 2026-08-31T17-06-34Z -slug: src-ui-py ---- -Method: dual-agent (A: frontend_design_reassessment · B: frontend_detector_reassessment) - -# ShiftPress Frontend Critique - -## Design Health Score - -| # | Nielsen heuristic | Score | Evidence-based verdict | -|---|---|---:|---| -| 1 | Visibility of system status | 4/4 | Exact local blockers, selected/not-included states, preflight-pending copy, progress percentage, cancellation, preserved outcomes, and contextual logs cover every consequential stage. | -| 2 | Match between system and real world | 4/4 | Night/Day schedules, recognizable template sources, printer, dates, document counts, preflight, and accurate sent-to-printer language match the physical workflow. | -| 3 | User control and freedom | 4/4 | Independent shifts, Apply/Cancel rollback, Reset run, Escape/cancel, preserved values, and taskbar-safe main/Setup overflow with focus reveal provide appropriate reversibility. | -| 4 | Consistency and standards | 4/4 | Night-to-Day ordering, neutral Setup, semantic blue/amber use, aligned mnemonics, standard ttk controls, Apply/Cancel behavior, and Windows work-area placement are coherent. | -| 5 | Error prevention | 4/4 | Pick-only dates, range correction, required-input blockers, distinct folder identity, disabled Print, selective preflight, large-batch confirmation, and processing locks prevent consequential mistakes. | -| 6 | Recognition rather than recall | 4/4 | Shift scope, dates, counts, drive/site/leaf sources, printer, blockers, Reset, and preflight state remain visible. | -| 7 | Flexibility and efficiency | 4/4 | Smart defaults, independent single/range batches, Reset run, keyboard shortcuts, mouse-wheel scrolling, and focus-follow overflow support novices and experts without excess features. | -| 8 | Aesthetic and minimalist design | 4/4 | The restrained native visual base has one primary action; Setup, scrollbars, progress, and logs appear only when relevant, and every normal-state control serves the task. | -| 9 | Error recognition, diagnosis, and recovery | 4/4 | Expected failures are specific and recoverable; fatal, configuration, and batch errors keep diagnostics in logs and give plain actions. Save failures are surfaced before printing and on close. | -| 10 | Help and documentation | 4/4 | Visible, concise task-focused Help covers Setup Apply/Cancel, Night/Day selection, preflight, Reset, shortcuts, and cancellation. | -| **Total** | | **40/40** | **Excellent** | - -## Design Specificity Verdict - -ShiftPress is an authored operator console rather than a generic settings form. Mirrored Night/Day scopes, shift-specific color semantics, today/tomorrow defaults, printer and template identity, preflight language, and the count-bearing Print action all map directly to schedule handoff. - -The deterministic detector returned `[]` with exit code 0 against the final `src/ui.py`. The detector is web-oriented and has no Tkinter-specific overlay surface, so the native app was also exercised directly. In the Windows 11 VM, the corrected packaged executable was responsive and its outer window rect `(230,0)-(1286,893)` fit inside the OS work area `(0,0)-(1517,894)`. The repository's obsolete pre-fix screenshot was removed rather than presented as current evidence. - -## Overall Impression - -The interface is focused, legible, truthful about readiness, and specific to a small high-consequence printing workflow. It maintains a restrained native aesthetic while making blockers, progress, outcomes, recovery, keyboard operation, and constrained-display behavior explicit. - -## What's Working - -1. Night and Day intent stays legible from selection through preflight, printing, cancellation, and final outcome. -2. Setup summarizes exact folder identity and printer state without implying readiness from stale strings. -3. Progressive disclosure keeps normal use quiet: scrollbars, progress, and logs appear only when needed. -4. Windows work-area sizing accounts for the taskbar and native frame, while focus-follow scrolling preserves access on constrained displays. -5. Save failures are visible at the two boundaries where the operator can act, while raw diagnostics remain in logs. - -## Priority Issues - -None. The final independent reassessment found no P0-P3 issue in the reviewed frontend scope. - -## Persona Red Flags - -No current red flags were found for the repeat operator, first-time operator, or keyboard/low-vision user within the reviewed scope. Physical-printer output remains a release-validation boundary rather than a source-demonstrated frontend defect. - -## Minor Observations - -- The native Tk implementation intentionally favors platform controls over custom animation or decorative effects. -- The deterministic web detector cannot assess Tk widget geometry, so native runtime checks remain part of the UI evidence. - -## Questions to Consider - -Questions skipped: 0 priority issues remain after the 40/40 reassessment. diff --git a/.impeccable/mocks/shiftpress-midnight-reference.json b/.impeccable/mocks/shiftpress-midnight-reference.json new file mode 100644 index 0000000..6e0b683 --- /dev/null +++ b/.impeccable/mocks/shiftpress-midnight-reference.json @@ -0,0 +1,12 @@ +{ + "prompt": "Use case: style-transfer\nAsset type: ShiftPress native Windows desktop application Midnight dark-theme reference mockup.\nInput image 1 is the edit target: the existing ShiftPress Rose reference. Preserve its EXACT client-area composition, framing, aspect ratio, typography, region positions, region sizes, spacings, borders, control types, icons, and control states. Make a strict theme-color variant only. Do not redesign or recompose.\nPrimary request: replace the Rose color palette with a consistently dark Midnight navy palette, and change ONLY the dropdown value \"Rose\" to \"Midnight\". Preserve all other visible text verbatim, including app name, subtitle, version, template folders, printer, labels, selected counts, and the different Night/Day dates.\nColor palette: window background #121821; panels/card bodies #1B2532; input fields #131C27; top header #101B2B; borders #44556B; primary text #F2F6FC; supporting text #B4C2D4; action/link/selected-control color #8EBBFF. Primary Print button fill #8EBBFF with dark text #102038. Existing pink subtitle, links, button outlines, printer icon, checkbox fills, and selected radio accents become #8EBBFF. Night card title #8BCFF5 on header fill #203248; Day card title #ECC17E on header fill #332D24. Keep the source's subtle native flat tonal treatment, using no glow or decorative gradients.\nText (verbatim, position unchanged): \"ShiftPress\"; \"Schedules, ready for the next shift.\"; \"Theme\"; \"Midnight\"; \"How to use\"; \"v3.0.0\"; \"Print setup\"; \"Night templates\"; \"Templates › Night\"; \"Day templates\"; \"Templates › Day\"; \"Printer\"; \"Microsoft Print to PDF\"; \"Setup...\"; \"Prepare print run\"; \"Choose exactly which schedules go to print.\"; \"Reset run\"; \"Night schedule\"; \"Day schedule\"; \"Include Night schedule\"; \"Include Day schedule\"; \"Single date\"; \"Date range\"; \"Date\"; \"09/05/2026\"; \"09/06/2026\"; \"Selected · 1 document\"; \"Print scope: 2 schedules selected\"; \"1. Night — 09/05/2026 — 1 document\"; \"2. Day — 09/06/2026 — 1 document\"; \"Preflight runs when you select Print.\"; \"Print 2 schedules\".\nConstraints: two peer schedule cards remain side by side with Night left and Day right; both included checkboxes stay checked; both Single date radios stay selected; both Date range radios stay unselected. Preserve the calendar and dropdown icons, printer icon, and all original controls. Complete client area only, no OS title bar or external frame. All surfaces remain dark, with no white panels or light-mode regions. No extra controls, labels, captions, palette swatches, imagery, decoration, watermarks, or logos. Retain exact composition and all dates/text except Rose becoming Midnight.", + "tool": "image_gen", + "mode": "built-in edit", + "generatedAt": "2026-09-05T08:40:43.301Z", + "referenceImage": ".impeccable/mocks/shiftpress-rose-reference.png", + "generatedImage": "/Users/ryan/.codex/generated_images/01a070b8-a25f-7aa0-a164-a5c11f119b3c/exec-16b8efc8-0e25-416c-b01a-0b6426cc9208.png", + "outputImage": ".impeccable/mocks/shiftpress-midnight-reference.png", + "theme": "Midnight", + "approved": false, + "review": "Generated dark Midnight palette variant preserving the source client-area composition, controls, states, labels, and dates; theme dropdown changed from Rose to Midnight. Image generation is a visual reference, not a guarantee of exact rendered hex values." +} diff --git a/.impeccable/mocks/shiftpress-midnight-reference.png b/.impeccable/mocks/shiftpress-midnight-reference.png new file mode 100644 index 0000000..e599ff6 Binary files /dev/null and b/.impeccable/mocks/shiftpress-midnight-reference.png differ diff --git a/.impeccable/mocks/shiftpress-rose-reference.json b/.impeccable/mocks/shiftpress-rose-reference.json new file mode 100644 index 0000000..b04831c --- /dev/null +++ b/.impeccable/mocks/shiftpress-rose-reference.json @@ -0,0 +1,6 @@ +{ + "prompt": "Use case: ui-mockup. Edit the prior ShiftPress mockup into the final DARK-ONLY Rose theme. Preserve its excellent functional layout, content order and straight-on desktop application client-area framing. No light mode anywhere. Output 1484x1060 pixels or the same aspect and framing as the input. This image is the measured reference for a real Tkinter Windows app.\n\nReplace every pale or white surface with deliberate dark surfaces: window #15131B, main panels #211D29, recessed inputs #191620, borders #514558, primary text #F8F3F8, supporting text #C0ADC3. Header band #22182A, title pure white, subtitle muted rose #E8A4CC. Rose/pink #E59AC8 is the key color: filled primary Print button with very dark #251522 text; selected radio/checkbox rose; outlined Setup button rose; Reset run rose. Night panel header #252339 and title #B7B5ED; Day panel header #35272B and title #E9B77E. All text must remain clearly legible, flat color fills, fine borders; no gradient, no glows, no shadows, no ornamental imagery.\n\nHeader: left \"ShiftPress\" and \"Schedules, ready for the next shift.\" Right place a native dropdown labeled \"Theme\" showing \"Rose\" with chevron, then outlined \"How to use\", then small \"v3.0.0\". Theme menu has only Rose and Midnight (not open).\nKeep setup strip: \"Print setup\" (sentence case, not all caps); groups Night templates / Templates › Night, Day templates / Templates › Day, Printer / Microsoft Print to PDF; Setup… button.\nKeep \"Prepare print run\", supporting instruction, and Reset run aligned right.\nKeep Night schedule and Day schedule peer cards with checked include controls, selected Single date vs Date range native radios side by side, date input 09/05/2026 and 09/06/2026, and Selected · 1 document.\nKeep full-width Print scope: 2 schedules selected, two exact job lines, printer name.\nBottom: \"Preflight runs when you select Print.\" and large pink \"Print 2 schedules\" button.\nMake this feel like a beautifully crafted professional Windows utility with rich plum-black surfaces and precisely balanced pink accents. No white panels, no light mode, no dashboard navigation, no new functionality beyond the theme selector.", + "tool": "built-in image_gen", + "purpose": "Dark-only Rose design reference requested by the user; native controls remain semantic.", + "reference": "shiftpress-rose-reference.png" +} diff --git a/.impeccable/mocks/shiftpress-rose-reference.png b/.impeccable/mocks/shiftpress-rose-reference.png new file mode 100644 index 0000000..8fe9d0e Binary files /dev/null and b/.impeccable/mocks/shiftpress-rose-reference.png differ diff --git a/.impeccable/review/midnight-calendar-windows.png b/.impeccable/review/midnight-calendar-windows.png new file mode 100644 index 0000000..a2ed71d Binary files /dev/null and b/.impeccable/review/midnight-calendar-windows.png differ diff --git a/.impeccable/review/midnight-date-menu.png b/.impeccable/review/midnight-date-menu.png new file mode 100644 index 0000000..75ac499 Binary files /dev/null and b/.impeccable/review/midnight-date-menu.png differ diff --git a/.impeccable/review/midnight-fitted-help.png b/.impeccable/review/midnight-fitted-help.png new file mode 100644 index 0000000..1c16af2 Binary files /dev/null and b/.impeccable/review/midnight-fitted-help.png differ diff --git a/.impeccable/review/midnight-help-short.png b/.impeccable/review/midnight-help-short.png new file mode 100644 index 0000000..b80837b Binary files /dev/null and b/.impeccable/review/midnight-help-short.png differ diff --git a/.impeccable/review/midnight-help.png b/.impeccable/review/midnight-help.png new file mode 100644 index 0000000..91889fb Binary files /dev/null and b/.impeccable/review/midnight-help.png differ diff --git a/.impeccable/review/midnight-printer-icon-main.png b/.impeccable/review/midnight-printer-icon-main.png new file mode 100644 index 0000000..87b8c02 Binary files /dev/null and b/.impeccable/review/midnight-printer-icon-main.png differ diff --git a/.impeccable/review/midnight-printer-icon-setup.png b/.impeccable/review/midnight-printer-icon-setup.png new file mode 100644 index 0000000..66d1fe3 Binary files /dev/null and b/.impeccable/review/midnight-printer-icon-setup.png differ diff --git a/.impeccable/review/midnight-printer-open.png b/.impeccable/review/midnight-printer-open.png new file mode 100644 index 0000000..d592309 Binary files /dev/null and b/.impeccable/review/midnight-printer-open.png differ diff --git a/.impeccable/review/midnight-setup-windows.png b/.impeccable/review/midnight-setup-windows.png new file mode 100644 index 0000000..fbfd1f4 Binary files /dev/null and b/.impeccable/review/midnight-setup-windows.png differ diff --git a/.impeccable/review/midnight-theme-open.png b/.impeccable/review/midnight-theme-open.png new file mode 100644 index 0000000..b9195a1 Binary files /dev/null and b/.impeccable/review/midnight-theme-open.png differ diff --git a/.impeccable/review/midnight-unified-help.png b/.impeccable/review/midnight-unified-help.png new file mode 100644 index 0000000..782ffe5 Binary files /dev/null and b/.impeccable/review/midnight-unified-help.png differ diff --git a/.impeccable/review/midnight-unified-main.png b/.impeccable/review/midnight-unified-main.png new file mode 100644 index 0000000..cc514f6 Binary files /dev/null and b/.impeccable/review/midnight-unified-main.png differ diff --git a/.impeccable/review/midnight-unified-setup.png b/.impeccable/review/midnight-unified-setup.png new file mode 100644 index 0000000..dd0fce4 Binary files /dev/null and b/.impeccable/review/midnight-unified-setup.png differ diff --git a/.impeccable/review/midnight-updated-window.png b/.impeccable/review/midnight-updated-window.png new file mode 100644 index 0000000..07d6a11 Binary files /dev/null and b/.impeccable/review/midnight-updated-window.png differ diff --git a/.impeccable/review/midnight-windows.png b/.impeccable/review/midnight-windows.png new file mode 100644 index 0000000..070d4d2 Binary files /dev/null and b/.impeccable/review/midnight-windows.png differ diff --git a/.impeccable/review/mixed-scope-windows.png b/.impeccable/review/mixed-scope-windows.png new file mode 100644 index 0000000..89caf2b Binary files /dev/null and b/.impeccable/review/mixed-scope-windows.png differ diff --git a/.impeccable/review/overflow-windows.png b/.impeccable/review/overflow-windows.png new file mode 100644 index 0000000..9502fdf Binary files /dev/null and b/.impeccable/review/overflow-windows.png differ diff --git a/.impeccable/review/processing-windows.png b/.impeccable/review/processing-windows.png new file mode 100644 index 0000000..f9dd1e4 Binary files /dev/null and b/.impeccable/review/processing-windows.png differ diff --git a/.impeccable/review/rose-calendar-windows.png b/.impeccable/review/rose-calendar-windows.png new file mode 100644 index 0000000..318f92d Binary files /dev/null and b/.impeccable/review/rose-calendar-windows.png differ diff --git a/.impeccable/review/rose-date-menu.png b/.impeccable/review/rose-date-menu.png new file mode 100644 index 0000000..77b4cf1 Binary files /dev/null and b/.impeccable/review/rose-date-menu.png differ diff --git a/.impeccable/review/rose-fitted-help.png b/.impeccable/review/rose-fitted-help.png new file mode 100644 index 0000000..2d96694 Binary files /dev/null and b/.impeccable/review/rose-fitted-help.png differ diff --git a/.impeccable/review/rose-help-short.png b/.impeccable/review/rose-help-short.png new file mode 100644 index 0000000..eeee5fb Binary files /dev/null and b/.impeccable/review/rose-help-short.png differ diff --git a/.impeccable/review/rose-help.png b/.impeccable/review/rose-help.png new file mode 100644 index 0000000..dd72be2 Binary files /dev/null and b/.impeccable/review/rose-help.png differ diff --git a/.impeccable/review/rose-printer-icon-main.png b/.impeccable/review/rose-printer-icon-main.png new file mode 100644 index 0000000..6bd929a Binary files /dev/null and b/.impeccable/review/rose-printer-icon-main.png differ diff --git a/.impeccable/review/rose-printer-icon-setup.png b/.impeccable/review/rose-printer-icon-setup.png new file mode 100644 index 0000000..4ef5950 Binary files /dev/null and b/.impeccable/review/rose-printer-icon-setup.png differ diff --git a/.impeccable/review/rose-printer-open.png b/.impeccable/review/rose-printer-open.png new file mode 100644 index 0000000..1ed0baf Binary files /dev/null and b/.impeccable/review/rose-printer-open.png differ diff --git a/.impeccable/review/rose-setup-windows.png b/.impeccable/review/rose-setup-windows.png new file mode 100644 index 0000000..c5d862c Binary files /dev/null and b/.impeccable/review/rose-setup-windows.png differ diff --git a/.impeccable/review/rose-short-window-scrolled.png b/.impeccable/review/rose-short-window-scrolled.png new file mode 100644 index 0000000..a9c5641 Binary files /dev/null and b/.impeccable/review/rose-short-window-scrolled.png differ diff --git a/.impeccable/review/rose-short-window.png b/.impeccable/review/rose-short-window.png new file mode 100644 index 0000000..fa564eb Binary files /dev/null and b/.impeccable/review/rose-short-window.png differ diff --git a/.impeccable/review/rose-theme-open.png b/.impeccable/review/rose-theme-open.png new file mode 100644 index 0000000..0270fde Binary files /dev/null and b/.impeccable/review/rose-theme-open.png differ diff --git a/.impeccable/review/rose-unified-help.png b/.impeccable/review/rose-unified-help.png new file mode 100644 index 0000000..b186b1f Binary files /dev/null and b/.impeccable/review/rose-unified-help.png differ diff --git a/.impeccable/review/rose-unified-main.png b/.impeccable/review/rose-unified-main.png new file mode 100644 index 0000000..8f20b26 Binary files /dev/null and b/.impeccable/review/rose-unified-main.png differ diff --git a/.impeccable/review/rose-unified-setup.png b/.impeccable/review/rose-unified-setup.png new file mode 100644 index 0000000..da998b3 Binary files /dev/null and b/.impeccable/review/rose-unified-setup.png differ diff --git a/.impeccable/review/rose-updated-window.png b/.impeccable/review/rose-updated-window.png new file mode 100644 index 0000000..c0cb44a Binary files /dev/null and b/.impeccable/review/rose-updated-window.png differ diff --git a/.impeccable/review/rose-windows.png b/.impeccable/review/rose-windows.png new file mode 100644 index 0000000..3d136f6 Binary files /dev/null and b/.impeccable/review/rose-windows.png differ diff --git a/.impeccable/surfaces/src-ui-py.md b/.impeccable/surfaces/src-ui-py.md index 43c732e..2dc4d48 100644 --- a/.impeccable/surfaces/src-ui-py.md +++ b/.impeccable/surfaces/src-ui-py.md @@ -7,52 +7,42 @@ related_targets: ["src/main.py"] ## Scope and Mode -- Surface: ShiftPress main Tkinter window (`src/ui.py`) -- Mode: Operate - -## Audience and Job - -An operator prepares deliberate Word-backed schedule print runs. The common -run is the current Night schedule plus the following Day schedule, while either -shift may independently use a single date or a range. - -## Primary Task and Content - -Confirm template/printer setup, independently include Night and Day work, set -each date scope, review the exact print manifest, then print. Both shifts must -remain visible. The manifest must state actual jobs and document counts. - -## Constraints - -- Standard Tkinter/ttk widgets only. -- Windows desktop, Microsoft Word COM, and physical printer workflow. -- Preserve cancellation, progress, preflight, and failure reporting. -- No custom-painted Canvas controls, web effects, hidden tabs, or image-based - widgets. A plain Canvas may provide native vertical overflow recovery. -- Do not claim readiness before preflight succeeds. +ShiftPress main Tkinter window, Setup, and How to use; mode: Operate. An operator +confirms setup, independently selects Night and Day dates/ranges, reviews the +exact manifest, and prints. The common run is Night today plus Day tomorrow. ## Chosen Direction -Low-risk Independent Shift Sections using the approved side-by-side -composition. Night uses written night-sky blue identity, Day uses written -daylight amber identity, and both share the same native control structure. - -## Memorable Moment - -The operator can compare both selected shift scopes at once, then read one -full-width manifest that says exactly what paper the button will produce. - -## Resolved Decisions - -Window dimensions are no longer fixed tokens. Both date modes are built to the -same height, and geometry is derived from Tk's computed requirement at launch. -When the rendered requirement exceeds the usable display, a native vertical -scrollbar appears and keyboard focus reveals the active control, keeping the -primary action reachable under Windows text scaling. - -Shift identity resolved to night-sky blue for Night and daylight amber for Day, -matching the sun-and-moon reading an operator already carries. - -The application icon was resolved as "the pair leads": two offset sheets, Night -behind and Day in front. Approved comp: -`.impeccable/mocks/icon-2-two-sheets-approved.png`. +Dark-only precision scheduling desk; two palettes, Midnight and pink-focused +Rose. Generated targets: [Rose](../mocks/shiftpress-rose-reference.png) and +[Midnight](../mocks/shiftpress-midnight-reference.png). The user requested image +fidelity, dark-only color choices, and more distinctive typography; these are +implementation targets, not a claim of separate image approval. + +THESIS: make the next shift's paper consequences effortless to inspect. +OWN-WORLD: dark ink, generous margins, precise native controls, tinted shift +headers; expressive headings over calm form text. +STORY: identify setup, select independent scopes, inspect output, print. +FIRST VIEWPORT: brand/theme/help rail, compact three-column setup, equal Night +and Day work areas, manifest and a single prominent action. +FORM: direction seed `16019748`, scheduling desk (candidate 3); horizontal +choice controls and full-width date rows. Native controls replace decorative +mockup pictograms and textures; typography follows the user's later request. + +[PRODUCT.md](../../PRODUCT.md) owns behavior; +[DESIGN.md](../../DESIGN.md) owns tokens, layout, interactions, and icon rules. +Read those guides only when the task concerns their contract. + +## Validation boundary + +Render real Tk widgets for clipping, equal-height date modes, keyboard focus, +disabled/degraded states, long values, and main/Setup overflow. Check Windows +work-area sizing and text scaling; both shifts and the primary action must +remain reachable. Mocked widget tests and web-oriented detectors cannot prove +native geometry or usability. Use genuine current runtime screenshots only. +Verify all dropdowns in both palettes, help keyboard/overflow behavior, and a +short main window with Print/Cancel visible before and after form scrolling. + +Readiness, progress, cancellation, and failure reporting must match the +controller. Complete the [Windows smoke test](../../docs/windows-smoke-test.md) +before claiming Word COM and physical printing are verified. diff --git a/AGENTS.md b/AGENTS.md index 35b591b..5d5d0ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,33 +1,31 @@ # ShiftPress repository instructions -`~/.codex/AGENTS.md` owns cross-project skill routing and engineering defaults. -This file adds only ShiftPress-specific commands, safety rules, and publishing -semantics. +`~/.codex/AGENTS.md` owns cross-project routing and engineering defaults. -## Working tree +## Checkout and documentation -- Inspect `git status --short --branch` before editing and before any commit or - push. Preserve unrelated changes and never reformat the whole repository in a - mixed worktree. -- `main` is the sole long-lived branch. Use temporary `codex/` branches for - proposed changes. - -## Sources of truth - -- `.github/workflows/build.yml` defines the quality gate, Windows test, artifact, - and release behavior; `.github/workflows/security.yml` defines the SonarQube - and Snyk gates. -- `src/__init__.py` is the only release-version source. Never hand-enter a - separate workflow version. -- `docs/windows-smoke-test.md` is the required real Word COM and physical-printer - release checklist. `.impeccable/` holds the approved UI references. +- Inspect `git status --short --branch` before editing, committing, or pushing. + Preserve unrelated changes; never reformat the whole mixed checkout. +- Use only the primary checkout on local `main`: no linked worktrees or local + feature branches. This project-specific exception overrides process-skill + isolation defaults. `main` is the only local and long-lived branch; authorized + publication uses a temporary remote `codex/` PR branch. +- Keep six canonical Markdown guides: this file (repository rules), + [README](README.md) (setup), [PRODUCT](PRODUCT.md) (behavior/migration), + [DESIGN](DESIGN.md) (visual rules), [surface brief](.impeccable/surfaces/src-ui-py.md) + (UI validation), and [Windows smoke test](docs/windows-smoke-test.md) (release + evidence). Read only the relevant guide. Consolidate durable decisions there; + keep temporary plans, critiques, and reports in chat or outside the repository. + Do not create persistent docs without a distinct ongoing need. ## Verification -Run the Python checks in the active project environment. If they are not on -`PATH`, use the corresponding executable under `.venv/bin/`. +`.github/workflows/build.yml` owns quality, Windows tests, artifacts, and +releases; `.github/workflows/security.yml` owns SonarQube/Snyk gates. -Before claiming a source, test, workflow, or configuration change complete, run: +For source, test, workflow, or configuration changes, start with focused tests, +then run the complete checks in the active Python environment (use `.venv/bin/` +executables when absent from `PATH`): ```bash black --check src tests @@ -38,22 +36,32 @@ node --test scripts/*.test.mjs git diff --check ``` -Start with focused tests for the changed behavior. The Python suite mocks -Windows-only modules on non-Windows hosts; report that limitation. Do not claim -real printing is verified unless the Windows checklist was completed with Word -and a physical printer. - -## Publishing and releases - -- "Push the changes" means push the exact verified tip to a temporary `codex/` - branch and open a pull request targeting `main`; never push directly or force - push to `main`. -- Merge only after `Build quality gate`, `SonarQube quality gate`, and - `Snyk security gate` all conclude successfully. Auto-merge may be enabled only - after the user authorizes a push. -- After merge, fetch and fast-forward local `main`, then require zero divergence - from `origin/main`. Every merged `main` push must produce its Windows artifact. -- A published release additionally requires the completed Windows smoke test, a - committed `src/__init__.py` version bump, and an explicit Build workflow - dispatch with `create_release` enabled. Never dispatch a release from an - unverified or uncommitted version. +For documentation-only changes, check Markdown formatting, retained links/file +references, and `git diff --check`; source checks are unnecessary. Non-Windows +Python tests mock Windows modules: report that limitation. Real printing is +verified only by completing the Windows checklist with Word and a physical +printer. Approved UI references live in `.impeccable/`. + +## Publication and releases + +- “Push the changes” authorizes publishing the exact verified local `main` tip + to a temporary remote `codex/` branch and opening a PR to `main`. Never push + directly or force push remote `main`. Auto-merge requires user push authority. +- Before merging, require successful `Build quality gate`, `windows-test`, + `SonarQube quality gate`, and `Snyk security gate`; all four must also be + required by GitHub branch protection. +- After merge, fetch and fast-forward local `main` when possible. For GitHub's + squash-only merge reconciliation, require a clean checkout, local HEAD equal + to the exact published PR head, identical HEAD/confirmed-merge trees, and the + confirmed merge commit in `origin/main` history. Save local `main` in a + verified external Git bundle, then `git reset --soft ` + and fast-forward to `origin/main`. If any check fails, preserve the checkout + and report it. Never hard reset or discard unrelated work. +- Finish with only local `main`, zero divergence from `origin/main`, and the + temporary remote PR branch deleted after confirmed merge. Verify each merged + `main` push produces its Windows artifact. +- Releases additionally require completed Windows smoke evidence, a committed + version bump in `src/__init__.py` (the sole version source), and explicit + user authorization for a Build dispatch on `main` with `create_release` + enabled. Never hand-enter a separate workflow version or dispatch a release + from an unverified/uncommitted version. diff --git a/DESIGN.md b/DESIGN.md index 603a547..f6cc669 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,182 +1,124 @@ --- name: ShiftPress -description: A low-risk native print console for deliberate shift-schedule runs. +description: A native print console for deliberate shift-schedule runs. --- # Design System: ShiftPress ## Overview -**Creative North Star: "The Operator's Print Desk"** - -ShiftPress should feel like a focused Windows utility an operator can trust -while preparing physical print jobs. It favors familiar controls, explicit -state, compact working density, and plain-language confirmation over decorative -metaphors. The interface may be distinctive through disciplined hierarchy and -semantic shift color, but it must remain visibly implementable with native -Tkinter/ttk widgets. - -The visual system is flat, quiet, and operational. Configuration, print intent, -validation, and execution should read as separate layers without hiding the -information that determines what paper will be produced. - -**Key Characteristics:** - -- Native Windows utility conventions -- Simultaneously visible related work -- Explicit enabled, disabled, ready, and error states -- Restrained dark surfaces with semantic shift accents -- Exact print scope and document counts near the primary action +“The Operator's Print Desk”: a dark precision scheduling desk with expressive +headings, generous margins, familiar native controls, and explicit paper +consequences. Tinted shift headers distinguish equal work areas; calm form text +keeps dates and counts easy to inspect. Build with standard Windows Tkinter/ttk +widgets; a plain Canvas provides native scrolling. ## Colors -Use a restrained dark neutral system with one action accent and narrow semantic -accents for shift identity. These values are the implemented source of truth in -`src/constants.py`. - -### Primary - -- **Action Blue** `#1267C9`: Reserved for the final print action. Use - `#0F56A8` for hover and pressed states. - -### Secondary - -- **Night Sky** `#38BDF8`: Identifies Night-shift controls and status, and marks - the selected day in a Night date picker. -- **Day Amber** `#F2B340`: Identifies Day-shift controls and status, and marks - the selected day in a Day date picker. -- **Progress Green** `#4ADE80`: Indicates active progress and successful - completion. -- **Error Rose** `#FB7185`: Indicates validation errors and the cancel action. - -### Neutral - -- **Window Charcoal** `#16171A`: Main application background. -- **Work Surface** `#24262A`: Raised group and card surfaces. -- **Recessed Input** `#1B1C20`: Entry and progress-trough surfaces. -- **Secondary Control** `#303238`: Neutral button surfaces and hover states. -- **Divider Steel** `#5A5D64`: Borders and separators. -- **Paper White** `#F4F4F5`: Primary text. -- **Muted Slate** `#B5B7BD`: Supporting labels and secondary status. - -### Named Rules - -**The Semantic Accent Rule.** Blue and amber identify Night and Day, following -the night-sky and daylight reading an operator already carries; saturated blue -identifies the print action. They are not ambient decoration. Night's sky blue -is lighter and less saturated than the action blue, so shift identity never -reads as a button. - -**The One Primary Action Rule.** Only the final print button receives the -strongest filled treatment. - -## Icon - -**Concept: the pair leads.** Two offset sheets — Night behind, Day in front — -on a charcoal ground. The mark states what the app is for: two shift schedules, -chosen independently. - -Geometry is a fraction of the canvas, so the mark is resolution independent. - -| Element | Value | -| --- | --- | -| Ground tile | `#16171A`, full bleed, corner radius `0.12` | -| Night sheet | `#38BDF8`, origin `(0.195, 0.155)`, size `0.42 × 0.50`, radius `0.035` | -| Day sheet | `#F2B340`, origin `(0.385, 0.345)`, size `0.42 × 0.50`, radius `0.035` | -| Separation gap | `0.035` of canvas, in ground colour, behind the Day sheet | - -Outputs are `icon.png` at 1024×1024 and `icon.ico` at 16/24/32/48/64/128/256, -generated by `tools/make_icon.py`. - -**The 16px Rule.** The separation gap exists so both sheets stay readable in the -Windows taskbar. At `0.020` the Night sheet collapses to a sliver; at `0.050` it -reduces to a bare corner. Any change to the geometry must be re-read at true -16px, not judged at full size. - -The icon takes no colour outside this system and carries no shadow or gradient, -per the Flat Native Rule. +[src/constants.py](src/constants.py) owns the exact `COLORS`, `THEMES`, and +`FONTS` tokens. [src/ui.py](src/ui.py) owns their native styles and state maps. +Reuse those sources rather than duplicating values in a second token catalog. + +- **Midnight:** ink-blue surfaces, a pale blue action accent, sky-blue Night, + and warm amber Day. +- **Rose:** plum-charcoal surfaces, a pink action accent, lavender Night, + and warm peach Day. Both choices remain dark throughout main, Setup, and + calendar and How to use surfaces; theme persistence follows [PRODUCT.md](PRODUCT.md). +- The strongest filled treatment belongs to Print. The action accent also + identifies Setup values, outlined actions, and selected native indicators. +- Green reinforces progress and success; error rose reinforces errors and + cancellation. Written labels identify shifts and state in both themes. + +Windows captions request dark styling, with explicit theme caption/text colors +on Windows 11. Apply styling after native ownership/frame creation, and inherit +the ShiftPress icon in every owned title bar. Caption colors are best effort +where the operating system supports them. ## Typography -**Display Font:** Segoe UI with the native system sans-serif fallback - -**Body Font:** Segoe UI with the native system sans-serif fallback - -**Label Font:** Segoe UI; use a monospaced system face only for diagnostic paths -or log output. +Use Bahnschrift for Windows brand, task, and card headings, with Segoe UI for +form text and controls. Development uses Avenir Next on macOS and Ubuntu on +Linux. The source-owned hierarchy gives the brand the largest size, then Setup, +task headings, and card titles; body and supporting text remain quieter. -**Character:** Familiar, workmanlike, and highly legible at ordinary Windows -desktop scaling. Hierarchy comes from size and weight, not all-caps decoration -or unusual letter spacing. - -### Hierarchy - -- **Headline:** Semibold and compact; names the current task. -- **Title:** Semibold; labels a schedule group or settings section. -- **Body:** Regular; explains state and consequences in plain language. -- **Label:** Regular or semibold; uses sentence case for fields and controls. - -### Named Rules - -**The Plain Label Rule.** Controls use sentence case and operational nouns: -"Include Night schedule", "Single date", and "Print 2 schedules". +Keep native point-sized fonts so Windows scales text normally. Use sentence +case and operational labels such as “Include Night schedule” and “Print 2 +schedules”; pluralize counts correctly. ## Layout -Use a single-window desktop work surface wide enough to show two peer task -groups side by side, with the exact default dimensions resolved against the -rendered Tkinter implementation and Windows text scaling. Related controls live -inside bordered native groups. The active print scope remains visible without -switching tabs or opening a modal. The window stays resizable and may expand -automatically when native content would otherwise clip. On a constrained -display or at high Windows text scaling, the work surface scrolls vertically -and follows keyboard focus so the primary action always remains reachable. - -Use a compact spacing rhythm with larger separation between configuration, -schedule intent, and execution. Setup appears as a safe summary by default; -`Setup…` opens a compact native Setup dialog so configuration never displaces -the shift cards, manifest, progress, or print action. `Apply` keeps edits; -`Cancel` restores the folders and printer present when Setup opened. Preserve a clear -top-to-bottom sequence: understand setup, choose work, confirm exact scope, -then print. - -**The Visible Scope Rule.** Information that changes what will print must remain -on the main surface; do not hide one shift behind tabs or secondary navigation. +- Sequence: brand/theme/help rail, compact three-column Setup summary, + preparation heading, peer Night/Day cards, full-width Print scope, then status + and one primary action. Dates, counts, printer, and blockers stay visible; + never hide a shift behind tabs or configuration. +- Use generous outer margins and shared panel spacing. Windows DPI scales + padding, gaps, dropdown arrows, and choice indicators alongside native text. +- Both cards use the same controls and equal-height single/range modes. Derive + geometry from Tk's rendered requirements and Windows work area, including + taskbar/frame allowances. Keep the window resizable; main and Setup overflow + must scroll vertically and follow keyboard focus at high text scaling. +- Reserve Print/Cancel and status outside the scrolling form. Short work areas + use tighter vertical gaps, not smaller fonts; permit narrower resizing than + the preferred launch width and scale window-frame allowances with Windows DPI. ## Elevation & Depth -The system is flat. It uses tonal surface changes, one-pixel borders, and -spacing rather than shadows, glow, glass, or layered translucency. Focus and -selection are state changes, not simulated physical elevation. - -**The Flat Native Rule.** No shadows are required for hierarchy; if a control -cannot be expressed cleanly with standard ttk state and border styling, simplify -the control. +Use flat tonal surfaces and one-pixel borders. Tinted title bands establish +shift identity; focus and selection change native states. Avoid shadows, glow, +gradients, browser effects, and image-based widgets. ## Shapes -Use native rectangular controls with square or gently softened corners. Avoid -large radii, pills, circular controls, and decorative clipping. Section identity -may use a narrow straight color bar or colored title text that is feasible with -ordinary frames and labels. +Use rectangular or gently softened native controls; avoid pills, large radii, +decorative circles, and clipping. Keep native checkbox and radio indicators. + +## Components + +- Setup identifies Night source, Day source, and printer separately with compact + recognizable folder identities. The native dialog preserves **Apply** and + **Cancel** rollback; opening it focuses the first field, Escape cancels. +- Shift panels use full-width tinted title bands, an include checkbox, + horizontal Single date/Date range choices, labels above full-width date rows, + and a neutral selected count. Range fields share the row evenly. +- Inputs have dark fields and visible borders. Calendar popups follow the theme, + using the shift accent with dark text for the selected day. Buttons distinguish + filled Print/Cancel, outlined Setup/help, and quieter Reset run/log actions. +- Theme and printer choices use dark native menus with selected-item indicators. + Their buttons and date pickers share one native field/arrow layout, height, + typography, padding, and visible focus borders. Date arrows are integrated, + not a separate narrow button; readonly fields have no light selection patch. +- How to use is a non-modal, themed reference window with numbered workflow + steps, selectable read-only text, overflow scrolling, and a fixed Close action. + On opening, measure the rendered text at its actual width and fit the window + to the content, capped to the monitor work area. Only show a scrollbar when + the screen or a user's resize genuinely leaves content out of view. + Escape closes it and returns focus to the help button without changing a run. ## Do's and Don'ts -### Do: +- Disable only locally invalid runs and explain the actionable blocker inline. + Keep selected counts neutral until preflight; expose checking, active work, + success, partial failure, and cancellation honestly. Errors name the affected + shift; valid peer cards retain their own status. +- Preserve explicit focus styles, keyboard navigation, Alt+S/P/H, Escape, + **Reset run**, and visible **How to use** guidance. Essential information + cannot live only in hover tooltips or color. Wrap long paths, printer names, + and status text; keep controls reachable when dependencies are unavailable. +- Reveal progress, scrollbars, and logs when relevant. Preserve meaningful + outcomes; diagnostic detail belongs in logs with plain recovery actions. + +## Icon -- **Do** build core interactions from standard Tkinter/ttk widgets already - present in the project. -- **Do** keep Night and Day visually distinct while giving both the same control - structure. -- **Do** state exact dates, ranges, document counts, printer, and readiness - before execution. -- **Do** preserve keyboard navigation, Windows scaling, and disabled states. +An ivory printer on neutral ink-charcoal, with equal blue and rose schedule +bars, fits both Midnight and Rose. Keep the same mark in both themes and in +every native title bar, taskbar, Explorer, and packaged executable. -### Don't: +[icon.png](icon.png) is the full-resolution image-generated master, including +its generation prompt metadata. `python tools/make_icon.py` exports `icon.ico` +(16/24/32/48/64/128/256px) directly from that master without replacing it. +The root's default Tk window icon propagates to owned dialogs; PyInstaller uses +the same ICO for the executable and bundles both assets. Pillow stays a +development-only dependency. Commit the master and exported ICO together. -- **Don't** use custom-painted gauges, animated canvases, browser effects, or - image-based controls. -- **Don't** let configured template folders imply that a shift is selected for - printing. -- **Don't** hide a selected schedule's dates or readiness behind a tab. -- **Don't** use color as the only indication of shift or validation state. +For behavior see [PRODUCT.md](PRODUCT.md); for native runtime validation see +the [surface brief](.impeccable/surfaces/src-ui-py.md). diff --git a/PRODUCT.md b/PRODUCT.md index 562c02c..8cca44b 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -6,71 +6,69 @@ web -## Users - -The primary user is an operator responsible for preparing and printing shift -schedules. The exact organizational role is not yet confirmed. - -## Product Purpose - -ShiftPress is a Windows desktop utility that turns Microsoft Word schedule -templates into dated print jobs. Success means the operator can deliberately -print only the schedules needed for a run without reprinting an already-used -shift. - -## Positioning - -ShiftPress combines shift-specific schedule rules, template lookup, date -replacement, printer selection, and auditable failure handling in one focused -operator workflow. - -## Operating Context - -- The app runs on Windows and controls Microsoft Word through COM automation. -- Day and Night schedules use separate template folders and naming rules. -- A common run prints the selected date's Night schedule and the following - date's Day schedule. -- Day and Night work must remain independent: each can use its own single date - or date range within the same run. -- Operators may also need single-schedule and multi-date batch runs. - -## Capabilities and Constraints - -- Preserve template-folder configuration, printer selection, date replacement, - preflight checks, background processing, cancellation, progress reporting, - and CSV failure reports. -- A print run must contain at least one selected schedule job. -- Validation and template preflight must apply only to enabled schedule jobs. -- Job counts, progress, confirmation copy, and completion summaries must reflect - the schedules actually selected. -- Windows, Microsoft Word, and `pywin32` are required for real document printing. -- The repository's automated tests mock Windows-only dependencies so logic and - quality gates can run outside Windows. -- The `web` platform value above describes the closest Impeccable interface - design-language category; the shipped product itself is a Windows desktop - Tkinter application. - -## Brand Commitments - -- Preserve the ShiftPress name. -- Application icons live at `icon.ico` and `icon.png`, regenerated from - `tools/make_icon.py`; see the Icon section of DESIGN.md. -- The interface should use direct, operational language and avoid implying that - configured template folders are automatically selected for printing. - -## Evidence on Hand - -- Current Tkinter implementation: `src/ui.py` -- Current controller and print workflow: `src/main.py` -- Existing automated tests: `tests/` -- Existing interface screenshot: `docs/screenshots/main.png` -- No user research, usage analytics, testimonials, or performance benchmarks - are currently available and future work must not fabricate them. - -## Product Principles - -1. Make print intent explicit before execution. -2. Keep Day and Night schedules independently controllable. -3. Show the exact job count and scope before paper is consumed. -4. Preserve fast repeat use for the common Night-then-next-Day workflow. -5. Fail before printing when required inputs or templates are unavailable. +This is an Impeccable design-language category only. ShiftPress ships as a +Windows desktop Tkinter/ttk application using Microsoft Word COM. + +## Users and Purpose + +Operators prepare dated shift schedules from Word templates. Success means +printing exactly the needed documents without reprinting an already-used shift. +No user research, analytics, testimonials, or performance benchmarks are +available; do not invent them. + +## Print contract + +- Night and Day are independently included and dated, each with a single date + or inclusive range. Fresh launch and **Reset run** enable both in Single date + mode: Night today, Day tomorrow, using local calendar dates. +- Persist only template folders, printer, and dark color theme, never dates, modes, or + include toggles. Disabling a shift or switching modes preserves its values; + only enabled shifts and their active modes contribute jobs or validation. +- At least one job is required. Invalid enabled dates, missing required folders, + missing printer selection, or unavailable date controls block Print locally. + Filesystem, template, Word, and device checks belong to preflight; selected + counts must never imply those checks already passed. +- Collect selections on the Tkinter thread and pass the same immutable manifest + through preflight, confirmation, and the worker; workers never read Tk state. + Jobs sort by date, with Night before Day for ties. Preview, large-batch + confirmation, progress, results, and CSV failures must describe those actual + jobs and the chosen printer. +- Preserve scheduling/template naming rules, range limits, root-contained paths, + selective template preflight, and ambiguity rejection. Disabled shifts need + no valid folder or template. Missing or ambiguous selected templates block + before document processing. +- Lock setup and selection controls during a batch; keep Cancel/Escape and + safe window-close behavior. Reset and keyboard Setup cannot bypass the lock. + Check cancellation before every document; an + active Word call is not interruptible. Always release Word and restore UI + state. Preserve bounded transient COM retries and per-document CSV failures. + Retain failures already recorded if the run is cancelled, aborted, or closed. +- Advance progress after a document attempt finishes. Distinguish full success, + partial failure, and cancellation, with accurate counts and shift/date errors. + “Sent to printer” does not establish that physical pages were printed. +- Open source documents read-only; replace dates across body/header/footer story + ranges. Fail closed if macros cannot be disabled, the requested printer cannot + be selected, any story traversal or date-processing operation fails, or no + supported date was replaced. Never modify source templates. Close documents + and quit Word without saving. A cleanup error after successful submission is + logged separately and must not turn the submitted job into a retryable failure. + +## Identity and settings + +Preserve the ShiftPress identity. `src/app_paths.py` retains active +`ShiftPress`/`.shiftpress` and legacy `ShiftPrint`/`.shiftprint` paths. When the +active config is absent, `src/config.py` checks the legacy data directory and +older working-directory config. An existing active config always wins. + +Write settings atomically. Rename a migration source to `config.json.migrated` +only after saving succeeds. If saving fails, keep the readable settings in memory +and the legacy file intact for retry. Migration failure must not prevent +startup; logs stay in their original location. Surface settings-save failures +before printing and on close. Keep the external Sonar key +`CrimsonSoul_ShiftPrint` to preserve the hosted project linkage. + +## Related guides + +[DESIGN.md](DESIGN.md) owns visual rules and icons. The +[Windows smoke test](docs/windows-smoke-test.md) owns real Word/physical-printer +evidence; automated tests alone do not establish that boundary. diff --git a/README.md b/README.md index c2e2ae3..b93472c 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,52 @@ # ShiftPress -ShiftPress is a Windows desktop app that batch-prints shift schedule templates via Microsoft Word COM automation. +A Windows desktop utility that batch-prints dated shift schedules from Microsoft +Word templates. Requires Windows, Microsoft Word, and a printer; the native +Tkinter/ttk app uses Python 3.12, pywin32, and tkcalendar. -![Platform](https://img.shields.io/badge/platform-Windows-0a7ea4) ![Language](https://img.shields.io/badge/language-Python%203.12-2ea043) ![UI](https://img.shields.io/badge/ui-Tkinter%2Fttk-4b5563) ![Automation](https://img.shields.io/badge/automation-Word%20COM-1f6feb) +## Run -## Snapshot +Use `setup.bat` to install dependencies and `start_app.bat` to launch, or run: -- Production-focused Word automation: open, replace, print, and close with cleanup safeguards -- Domain logic handles complex monthly rotation scheduling (e.g. "third Thursday" patterns) -- Defensive workflow with preflight checks, per-document retry paths, and CSV failure reporting -- Modular architecture with strong unit-test coverage and static quality gates -- Packaged as a single-file executable for non-technical end users via PyInstaller - -## Core Features - -- Independent Day and Night print selections with single-date or date-range - scope for each shift -- Exact preflight-neutral print manifest showing the selected dates, document - count, and printer before a run starts -- Date replacement across body, header, and footer story ranges -- Template path, printer, and date-range preflight validation before any processing begins -- Ambiguous template names are rejected at preflight rather than resolved silently -- Per-document retry handling for transient COM errors with structured failure logging -- Cancelable background processing with responsive UI progress updates -- Timestamped CSV failure reports for audit and retry workflows - -## Architecture - -- `src/main.py` — orchestration and workflow control -- `src/ui.py` — Tkinter/ttk interface layer -- `src/word_processor.py` — Word COM automation lifecycle (open, replace, print, close) -- `src/print_manifest.py` — shift selection, validation, and print manifest construction -- `src/scheduler.py` — date resolution and template path logic -- `src/config.py` — config management -- `src/path_validation.py` — path traversal and filename safety checks - -## Tech Stack - -| Layer | Technology | -| --- | --- | -| Language | Python 3.12 | -| UI | Tkinter/ttk | -| Office integration | pywin32 (Word COM) | -| Date picker | tkcalendar | -| Testing | pytest + pytest-cov | -| Quality | black + mypy + pylint | -| Packaging | PyInstaller | - -## Quick Start - -```bash +```bat python -m venv .venv .venv\Scripts\activate pip install -r requirements.txt python main.py ``` -Windows helper scripts: - -- `setup.bat` — installs all dependencies into a virtualenv -- `start_app.bat` — activates the environment and launches the app - -## Quality and Testing - -```bash -pip install -r requirements-dev.txt -pytest # run test suite with coverage -black --check src tests # formatting check -mypy src # type checking -pylint src --fail-under=8.0 # linting gate -``` - -All four gates run in CI on pushes and pull requests to `main`. The test suite -mocks all Windows-only modules so it can run on any platform. - -Before a release, use the -[Windows print smoke test](docs/windows-smoke-test.md) to verify real Word COM -and physical printer behavior for Night-only, Day-only, both-shift, and mixed -scope runs. - -## Releases - -`main` is the sole long-lived branch. Every push to it runs the quality gates, -then builds a Windows executable and uploads it as -`ShiftPress-v-`, so a downloadable build always exists for the -commit you are working on. Push builds expire after 7 days. - -The version is read from `__version__` in `src/__init__.py`; it is not entered -by hand. To cut a published release: - -1. Bump `__version__` and commit. -2. Run the **Build** workflow via `workflow_dispatch` with `create_release` - enabled. - -The build runs only after the quality gates pass, and the tag, artifact name, -and in-app version all come from that one value. - -## Security - -- Word documents open in read-only mode during processing; originals are never modified -- Word macros are force-disabled before document open; ShiftPress stops if that safety setting cannot be applied -- Path validation blocks traversal outside configured template root directories -- Template names that collide after normalization are rejected, so a run can never - print an arbitrary file chosen by directory order -- Date range limits prevent runaway batch operations -- Config writes are atomic; all operations are logged with structured timestamps - -## Project Layout - -- `main.py` — top-level entry point -- `src/` — application modules (controller, UI, scheduler, COM processor, config, validation) -- `tests/` — unit tests and module fixtures -- `.github/workflows/build.yml` — quality gates on every push, plus the - on-demand Windows build and release workflow via PyInstaller - -## License - -MIT (see `LICENSE`) +1. Open **Setup…**, choose Night/Day template folders and a printer, then + **Apply**. **Cancel** restores the settings present when Setup opened. +2. Include Night, Day, or both; choose a single date or independent range for + each. A fresh run defaults to Night today and Day tomorrow. +3. Review **Print scope** and the document count, then print. Preflight checks + the selected templates before processing starts. **Cancel** stops before the + next document; an active Word call must finish first. + +**Reset run** restores the daily defaults. **How to use** explains the workflow; +keyboard shortcuts are Alt+S for Setup, Alt+P for Print, Alt+H for Help, and +Escape for cancellation. + +Templates remain unchanged. Date replacement covers body, header, and footer +story ranges. Printing stops if macros cannot be disabled, the requested +printer cannot be selected, or no supported date text is found. Missing or +ambiguous templates block preflight. Failed documents receive CSV reports. + +## Development and releases + +Install `requirements-dev.txt` for development. [AGENTS.md](AGENTS.md) owns +verification commands and protected publication/release rules. Non-Windows +tests mock Windows dependencies; real Word COM and physical printing require +the [Windows smoke test](docs/windows-smoke-test.md) before release. + +Each merged `main` push produces a PyInstaller executable artifact named +`ShiftPress-v-`, retained for seven days. Release versions come +only from `src/__init__.py`; a published release requires a committed bump and +an explicitly authorized Build dispatch on `main` with `create_release` enabled. + +Read [PRODUCT.md](PRODUCT.md) for behavior, [DESIGN.md](DESIGN.md) for native UI +rules, and the [surface brief](.impeccable/surfaces/src-ui-py.md) for UI scope. +The controller is `src/main.py`, UI `src/ui.py`, job model +`src/print_manifest.py`, and Word integration `src/word_processor.py`. + +MIT licensed; see [LICENSE](LICENSE). diff --git a/docs/plans/2026-03-03-icon-and-rename-design.md b/docs/plans/2026-03-03-icon-and-rename-design.md deleted file mode 100644 index eb6e6ef..0000000 --- a/docs/plans/2026-03-03-icon-and-rename-design.md +++ /dev/null @@ -1,33 +0,0 @@ -# ShiftPress Icon Redesign & Repository Rename - -**Date**: 2026-03-03 - -## Icon - -- **Concept**: Calendar page with downward press/print arrow -- **Colors**: Amber (#F59E0B) on Zinc-900 (#18181B) rounded-square background -- **Calendar element**: Simplified page outline with top header bar and horizontal date-row lines -- **Press element**: Bold downward arrow overlapping bottom-right of calendar -- **Sizes**: 1024x1024 PNG source; ICO with 16/24/32/48/64/128/256px variants -- **Method**: SVG created programmatically, converted via Pillow/cairosvg to PNG and ICO -- **Files replaced**: `icon.png`, `icon.ico` (same filenames, no build config changes needed) - -## Repository Rename - -- **GitHub**: `CrimsonSoul/shift-automator-pro` -> `CrimsonSoul/ShiftPress` via `gh repo rename` -- **Local folder**: `/Users/ryan/Apps/shift-automator-pro` -> `/Users/ryan/Apps/ShiftPress` -- **Remote URL**: Updated to match new repo name -- **Redirects**: GitHub auto-redirects old URL; existing clones continue working - -## Implementation Steps - -1. Create SVG icon matching the design spec -2. Install cairosvg + Pillow if needed -3. Render SVG to 1024x1024 PNG -4. Generate multi-size ICO from PNG -5. Replace `icon.png` and `icon.ico` in repo root -6. Rename GitHub repo via `gh repo rename ShiftPress` -7. Rename local folder to `/Users/ryan/Apps/ShiftPress` -8. Update git remote URL -9. Verify build config still references correct icon files -10. Run tests, commit, push diff --git a/docs/superpowers/plans/2026-07-30-independent-shift-printing.md b/docs/superpowers/plans/2026-07-30-independent-shift-printing.md deleted file mode 100644 index cb9718e..0000000 --- a/docs/superpowers/plans/2026-07-30-independent-shift-printing.md +++ /dev/null @@ -1,918 +0,0 @@ -# Independent Shift Printing Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the shared Day/Night date range with independent, side-by-side Night and Day selections, and drive validation, preflight, progress, printing, and results from one exact immutable print manifest. - -**Architecture:** Add a small pure `print_manifest` module for shift-selection and concrete-job types. The Tkinter UI owns only native widget state and exposes immutable selections; the controller validates those selections into a frozen batch request before starting the worker. The worker iterates that request's manifest without reading Tkinter state or reconstructing dates. - -**Tech Stack:** Python 3.12, Tkinter/ttk, tkcalendar `DateEntry`, pytest, unittest.mock, Black, mypy, pylint, Microsoft Word COM through the existing `WordProcessor`. - -## Global Constraints - -- Keep the approved side-by-side native Tkinter composition. Do not add Canvas controls, browser UI, image buttons, animation, or custom-drawn widgets. -- Both shifts default enabled in Single date mode. Night defaults to local today; Day defaults to the following local calendar date. -- Disabling a shift preserves its date and mode values but excludes it from validation, preflight, confirmation, progress, printing, and results. -- Continue persisting only the existing Day folder, Night folder, and printer. Never persist dates, modes, or include toggles. -- Build the final manifest once on the Tkinter thread after validation. Pass that exact immutable tuple to preflight, confirmation, and the worker. -- Sort jobs chronologically, with Night before Day only when two jobs share a date. -- Preserve cancellation before every document, the preflight `WordProcessor` cache, CSV failure reports, thread cleanup, and window-close behavior. -- Keep all existing repository behavior green. Do not change template naming, Word replacement, or printer semantics. -- Treat macOS/Linux tests as automation coverage only. Do not claim Word COM or physical printing is verified until the recorded Windows smoke test passes. -- Preserve unrelated worktree changes. Do not push unless the user separately asks. - ---- - -## Implementation Environment - -The current machine has Homebrew but no `python3.12`. Prepare the repository's -ignored virtual environment before Task 1: - -```bash -command -v python3.12 || brew install python@3.12 -/opt/homebrew/bin/python3.12 -m venv .venv -.venv/bin/python -m pip install --upgrade pip -.venv/bin/python -m pip install -r requirements-dev.txt -.venv/bin/python --version -``` - -Expected: the last command reports Python 3.12.x. `.venv/` remains ignored. - ---- - -## Task 1: Introduce the Pure Print Manifest - -**Files:** - -- Create: `src/print_manifest.py` -- Create: `tests/test_print_manifest.py` -- Modify: `src/constants.py:48,109-112` -- Delete during Task 4: `src/main.py:56-69` - -- [ ] **Step 1: Write failing manifest tests** - -Create `tests/test_print_manifest.py` with fixed dates and these cases: - -```python -from datetime import date - -import pytest - -from src.print_manifest import PrintJob, ShiftSelection, build_print_manifest - - -def selection( - shift_type: str, - *, - enabled: bool = True, - mode: str = "single", - start: date = date(2026, 7, 30), - end: date = date(2026, 7, 30), - folder: str = "/templates", -) -> ShiftSelection: - return ShiftSelection( - shift_type=shift_type, - enabled=enabled, - mode=mode, - start_date=start, - end_date=end, - folder=folder, - ) - - -def test_builds_night_only_single_date() -> None: - manifest = build_print_manifest((selection("night"),)) - assert manifest == ( - PrintJob( - date=date(2026, 7, 30), - shift_type="night", - template_name="Thursday Night", - folder="/templates", - ), - ) - - -def test_ignores_disabled_shift() -> None: - manifest = build_print_manifest( - (selection("night"), selection("day", enabled=False)) - ) - assert [job.shift_type for job in manifest] == ["night"] - - -def test_uses_single_date_even_when_saved_range_end_differs() -> None: - manifest = build_print_manifest( - ( - selection( - "day", - mode="single", - start=date(2026, 7, 31), - end=date(2026, 8, 8), - ), - ) - ) - assert [job.date for job in manifest] == [date(2026, 7, 31)] - - -def test_expands_independent_ranges_and_sorts_chronologically() -> None: - manifest = build_print_manifest( - ( - selection( - "night", - mode="range", - start=date(2026, 7, 31), - end=date(2026, 8, 1), - folder="/night", - ), - selection( - "day", - mode="range", - start=date(2026, 7, 30), - end=date(2026, 7, 31), - folder="/day", - ), - ) - ) - assert [(job.date, job.shift_type) for job in manifest] == [ - (date(2026, 7, 30), "day"), - (date(2026, 7, 31), "night"), - (date(2026, 7, 31), "day"), - (date(2026, 8, 1), "night"), - ] - - -@pytest.mark.parametrize("mode", ["weekly", ""]) -def test_rejects_unknown_mode(mode: str) -> None: - with pytest.raises(ValueError, match="mode"): - build_print_manifest((selection("night", mode=mode),)) - - -def test_rejects_missing_active_date() -> None: - item = ShiftSelection( - shift_type="day", - enabled=True, - mode="single", - start_date=None, - end_date=None, - folder="/day", - ) - with pytest.raises(ValueError, match="Day date"): - build_print_manifest((item,)) -``` - -Include a separate tie-break test asserting Night precedes Day on the same date, and a template-name test covering the existing `THIRD Thursday` Day rule. - -- [ ] **Step 2: Run the new tests and confirm the RED state** - -Run: - -```bash -.venv/bin/python -m pytest tests/test_print_manifest.py -q -``` - -Expected: collection fails with `ModuleNotFoundError: No module named 'src.print_manifest'`. - -- [ ] **Step 3: Implement immutable selection and job types** - -Create `src/print_manifest.py` with this public shape: - -```python -from dataclasses import dataclass -from datetime import date -from typing import Literal, Optional, Sequence, cast - -from .scheduler import get_date_range, get_shift_template_name - -ShiftType = Literal["night", "day"] -DateMode = Literal["single", "range"] - - -@dataclass(frozen=True) -class ShiftSelection: - shift_type: ShiftType - enabled: bool - mode: DateMode - start_date: Optional[date] - end_date: Optional[date] - folder: str - - def active_range(self) -> tuple[date, date]: - label = self.shift_type.title() - if self.start_date is None: - raise ValueError(f"Select a {label} date") - if self.mode == "single": - return self.start_date, self.start_date - if self.mode != "range": - raise ValueError(f"Invalid {label} date mode") - if self.end_date is None: - raise ValueError(f"Select a {label} range end date") - return self.start_date, self.end_date - - -@dataclass(frozen=True) -class PrintJob: - date: date - shift_type: ShiftType - template_name: str - folder: str - - -def build_print_manifest( - selections: Sequence[ShiftSelection], -) -> tuple[PrintJob, ...]: - jobs: list[PrintJob] = [] - for selection in selections: - if not selection.enabled: - continue - start_date, end_date = selection.active_range() - for scheduled_date in get_date_range(start_date, end_date): - jobs.append( - PrintJob( - date=scheduled_date, - shift_type=selection.shift_type, - template_name=get_shift_template_name( - scheduled_date, selection.shift_type - ), - folder=selection.folder, - ) - ) - return tuple( - sorted( - jobs, - key=lambda job: ( - job.date, - 0 if job.shift_type == "night" else 1, - ), - ) - ) -``` - -Use explicit runtime guards before `cast` if mypy requires the test helper to pass plain strings. Do not silently coerce invalid shift or mode strings. - -Change `LARGE_BATCH_THRESHOLD` in `src/constants.py` to mean concrete documents: - -```python -LARGE_BATCH_THRESHOLD: Final = 30 # documents — prompt user for confirmation -``` - -Delete `_compute_batch_size` only after its callers have been migrated in Tasks 3 and 4. - -- [ ] **Step 4: Run focused tests and confirm GREEN** - -Run: - -```bash -.venv/bin/python -m pytest tests/test_print_manifest.py tests/test_scheduler.py -q -``` - -Expected: all manifest and scheduler tests pass. - -- [ ] **Step 5: Commit the pure model** - -```bash -git add src/print_manifest.py src/constants.py tests/test_print_manifest.py -git commit -m "feat: add independent shift print manifest" -``` - ---- - -## Task 2: Build the Side-by-Side Native Shift UI - -**Files:** - -- Modify: `src/ui.py:1-35,164-229,434-904` -- Modify: `src/constants.py:99-143` -- Modify: `tests/test_ui.py:1-119` - -- [ ] **Step 1: Expand the UI fixture and write failing behavior tests** - -Update the widget patches in `tests/test_ui.py` to include `ttk.Radiobutton`. Instantiate with a fixed clock: - -```python -ui = ScheduleAppUI(root, today=date(2026, 7, 30)) -``` - -Add focused tests for: - -```python -def test_defaults_night_today_and_day_tomorrow(ui) -> None: - night, day = ui.get_shift_selections() - assert (night.enabled, night.mode, night.start_date) == ( - True, - "single", - date(2026, 7, 30), - ) - assert (day.enabled, day.mode, day.start_date) == ( - True, - "single", - date(2026, 7, 31), - ) - - -def test_shift_selections_are_independent(ui) -> None: - # Assign explicit mock variables and pickers to both panel records. - # Night is enabled/range; Day is disabled/single. - night, day = ui.get_shift_selections() - assert night != day - assert night.mode == "range" - assert day.enabled is False - - -def test_disabling_night_does_not_disable_day_controls(ui) -> None: - ui._shift_panels["night"].enabled_var.get.return_value = False - ui._shift_panels["day"].enabled_var.get.return_value = True - ui._sync_shift_panel_state("night") - ui._shift_panels["night"].single_radio.config.assert_called_with( - state="disabled" - ) - ui._shift_panels["day"].single_radio.config.assert_not_called_with( - state="disabled" - ) - - -def test_mode_change_preserves_hidden_picker_values(ui) -> None: - # Change the mode variable and call _sync_shift_panel_state. - # Assert config/grid methods change, but no picker receives set_date(). - - -def test_manifest_preview_updates_count_and_button(ui) -> None: - ui._update_manifest_preview() - ui.manifest_label.config.assert_called() - ui.print_btn.config.assert_any_call(text="Print 2 schedules") - - -def test_set_inputs_enabled_controls_every_shift_widget(ui) -> None: - ui.set_inputs_enabled(False) - # Assert setup controls, include toggles, radios, and all six DateEntry - # widgets receive state="disabled". -``` - -Retain the current status, dialogs, start-command, and folder-getter tests. - -- [ ] **Step 2: Run the UI tests and confirm the RED state** - -Run: - -```bash -.venv/bin/python -m pytest tests/test_ui.py -q -``` - -Expected: failures because `ScheduleAppUI` does not accept `today`, has no `_shift_panels`, and still exposes one shared date range. - -- [ ] **Step 3: Add native shift-panel state and exact defaults** - -In `src/ui.py`: - -- import `dataclass`, `timedelta`, and the manifest types/helper; -- add a private `_ShiftPanelWidgets` dataclass holding the include variable, mode variable, checkbutton, two radiobuttons, three date pickers, and the single/range wrapper frames; -- change the constructor to `ScheduleAppUI(root, today: Optional[date] = None)`; -- store `self._today = today or date.today()`; -- initialize `self._shift_panels: dict[ShiftType, _ShiftPanelWidgets] = {}`; -- initialize `self.manifest_label` and retain `self.print_btn`. - -Use these exact initial dimensions and Day token in `src/constants.py`: - -```python -WINDOW_WIDTH: Final = 980 -WINDOW_HEIGHT: Final = 820 - -@dataclass(frozen=True) -class Colors: - # existing values remain unchanged - day_accent: str = "#38BDF8" # Sky-400 -``` - -Add `Night.TLabelframe.Label` and `Day.TLabelframe.Label` styles. Both panels must also contain the written shift name and Include label so color is supplementary. - -- [ ] **Step 4: Replace the shared cards with the approved composition** - -Refactor `_create_widgets` into this order: - -```python -self._create_header(bg_canvas) -self._create_setup_card(bg_canvas) -self._create_shift_selection_row(bg_canvas) -self._create_manifest_card(bg_canvas) -self._create_footer(bg_canvas) -``` - -Implementation details: - -- `_create_setup_card` contains Day Templates, Night Templates, and Printer. -- `_create_shift_selection_row` uses a `ttk.Frame` with `grid_columnconfigure(0, weight=1)` and `grid_columnconfigure(1, weight=1)`. -- Put Night in column 0 and Day in column 1 with equal padding. -- `_create_shift_panel` creates only `ttk.LabelFrame`, `ttk.Checkbutton`, `ttk.Radiobutton`, `ttk.Frame`, `ttk.Label`, and existing `DateEntry` widgets. -- Night defaults to `self._today`; Day defaults to `self._today + timedelta(days=1)`. -- Single and range picker values are all initialized to that shift's default. -- `_sync_shift_panel_state(shift_type)` disables only that shift's mode/date controls when excluded, uses `grid`/`grid_remove` for the active date mode, and never calls `set_date`. -- Bind every `<>`, include command, and mode command to state synchronization plus manifest refresh. -- Preserve the existing behavior that advances a range end when its start moves past it, but scope it to the affected shift panel. - -Expose the new controller boundary: - -```python -def get_shift_selections(self) -> tuple[ShiftSelection, ShiftSelection]: - return ( - self._get_shift_selection("night"), - self._get_shift_selection("day"), - ) -``` - -`_get_shift_selection` must read only the selected shift's variables and pickers. For Single mode it may retain the hidden range end in the dataclass, because `active_range()` ignores it. - -The manifest card should show: - -- `This run: N schedules` -- one line per enabled shift with its active date or range and count; -- `Printer: ` or `Printer: Choose a printer`; -- button text `Print 1 schedule` or `Print N schedules`. - -When no shift is included, show `This run: No schedules selected` and use `Print schedules`. Do not display `Ready` before controller preflight. - -- [ ] **Step 5: Make processing lock state composable** - -Implement a UI-wide flag such as `self._inputs_enabled`. `set_inputs_enabled(False)` disables: - -- both folder entries and their Browse buttons; -- printer dropdown and Refresh; -- both Include checkbuttons; -- all mode radios and date pickers. - -`set_inputs_enabled(True)` restores Setup controls and Include checkbuttons, then calls `_sync_shift_panel_state` for each shift so an excluded shift remains visually disabled. Store Browse buttons when creating path rows so they can be locked. - -Add a public `refresh_manifest_preview()` method for Task 4's `_reset_ui` to -call instead of hard-coding `Print Schedules`. - -- [ ] **Step 6: Run focused UI and manifest tests** - -Run: - -```bash -.venv/bin/python -m pytest tests/test_ui.py tests/test_print_manifest.py -q -``` - -Expected: all focused tests pass. - -- [ ] **Step 7: Commit the native UI** - -```bash -git add src/ui.py src/constants.py tests/test_ui.py -git commit -m "feat: add independent native shift controls" -``` - ---- - -## Task 3: Validate and Preflight Only the Selected Manifest - -**Files:** - -- Modify: `src/main.py:1-371` -- Modify: `tests/test_main.py:1-104,286-351,456-482,509-530` - -- [ ] **Step 1: Replace shared-range test fixtures with selections** - -Import `dataclass`, `replace`, `PrintJob`, `ShiftSelection`, and `build_print_manifest` where needed. Add a frozen request next to `FailedOperation`: - -```python -@dataclass(frozen=True) -class _BatchRequest: - manifest: tuple[PrintJob, ...] - printer_name: str - day_folder: str - night_folder: str -``` - -Change the app fixture to return independent selections: - -```python -mock_ui.get_shift_selections.return_value = ( - ShiftSelection( - shift_type="night", - enabled=True, - mode="single", - start_date=date(2026, 1, 14), - end_date=date(2026, 1, 14), - folder="/tmp/night", - ), - ShiftSelection( - shift_type="day", - enabled=True, - mode="single", - start_date=date(2026, 1, 15), - end_date=date(2026, 1, 15), - folder="/tmp/day", - ), -) -``` - -Write failing tests that assert: - -- neither enabled returns `Select at least one Night or Day schedule`; -- a missing disabled Day folder does not call `validate_folder_path` for Day; -- a missing enabled Night folder produces a Night-specific error; -- a reversed Day range produces the existing date-range error labeled Day; -- Night-only preflight looks up only the Night template; -- selected missing and ambiguous templates still fail; -- success returns a `_BatchRequest` whose manifest contains exactly the selected jobs; -- quoted folder paths are normalized once in both request folders and job folders. - -Use a return contract of: - -```python -request, error = app._validate_inputs() -assert request is not None -assert error is None -``` - -- [ ] **Step 2: Run validation/preflight tests and confirm RED** - -Run: - -```bash -.venv/bin/python -m pytest tests/test_main.py \ - -k "validate_inputs or preflight_templates or start_processing_batch_params" -q -``` - -Expected: failures because `_validate_inputs` still requires both folders and one shared range, and `_preflight_templates` synthesizes both shifts. - -- [ ] **Step 3: Build one validated batch request** - -Change `_validate_inputs` to: - -```python -def _validate_inputs( - self, -) -> tuple[Optional[_BatchRequest], Optional[str]]: -``` - -Apply validation in the approved order: - -1. Read both selections once from `ui.get_shift_selections()`. -2. Normalize both folder strings with the existing whitespace/quote stripping. -3. Require at least one enabled selection. -4. For each enabled selection, call `active_range()` and then `validate_date_range`. -5. Validate the printer once and check it against the available-printer snapshot. -6. Check Word automation availability. -7. Validate only enabled folders. -8. Build one immutable manifest with `build_print_manifest`. -9. Preflight that manifest. -10. Return `_BatchRequest(manifest, printer_name, day_folder, night_folder)`. - -Use `dataclasses.replace` to put normalized folder values back into selections before manifest construction. Preserve both configured folders in `_BatchRequest`, even when one shift is disabled, so configuration persistence does not change. - -Change `_preflight_templates` to accept only concrete jobs: - -```python -def _preflight_templates( - self, manifest: tuple[PrintJob, ...] -) -> tuple[bool, Optional[str]]: -``` - -Deduplicate checks by `(job.folder, job.shift_type, job.template_name)`. Keep the existing `TemplateLookupError`, truncation, warm-cache, and missing-template message behavior. Label every issue with `Night` or `Day`. - -- [ ] **Step 4: Make start confirmation consume the validated request** - -In `start_processing`: - -```python -request, error_msg = self._validate_inputs() -if request is None: - self.ui.show_warning("Validation Error", error_msg or "Unknown error") - return -``` - -For `len(request.manifest) >= LARGE_BATCH_THRESHOLD`, confirm using the exact document count and concise per-shift scope. Never call the removed shared-date getters and never recalculate `days × 2`. - -Pass `request` directly to the worker: - -```python -self._processing_thread = threading.Thread( - target=self._process_batch, - args=(request,), - daemon=False, -) -``` - -- [ ] **Step 5: Run the focused controller tests** - -Run: - -```bash -.venv/bin/python -m pytest tests/test_main.py \ - -k "validate_inputs or preflight_templates or start_processing" -q -``` - -Expected: all selected validation, preflight, and request-handoff tests pass. - -- [ ] **Step 6: Commit validation and preflight** - -```bash -git add src/main.py tests/test_main.py -git commit -m "refactor: validate selected shift manifest" -``` - ---- - -## Task 4: Execute, Report, and Cancel by Concrete Job - -**Files:** - -- Modify: `src/main.py:46-69,375-570` -- Modify: `tests/test_main.py:105-285,379-455,509-530` - -- [ ] **Step 1: Write failing worker tests using explicit manifests** - -Replace old `start_date`/`end_date` dictionaries with `_BatchRequest` instances. Add or update tests for: - -```python -def test_processes_night_today_then_day_tomorrow(self, app) -> None: - mock_wp = MagicMock() - mock_wp.__enter__.return_value = mock_wp - mock_wp.__exit__.return_value = False - mock_wp.print_document.return_value = (True, None) - app._preflight_wp = mock_wp - - request = _BatchRequest( - manifest=( - PrintJob( - date=date(2026, 1, 14), - shift_type="night", - template_name="Wednesday Night", - folder="/tmp/night", - ), - PrintJob( - date=date(2026, 1, 15), - shift_type="day", - template_name="THIRD Thursday", - folder="/tmp/day", - ), - ), - printer_name="Test Printer", - day_folder="/tmp/day", - night_folder="/tmp/night", - ) - - app._process_batch(request) - - assert [call.args[:3] for call in mock_wp.print_document.call_args_list] == [ - ("/tmp/night", "Wednesday Night", date(2026, 1, 14)), - ("/tmp/day", "THIRD Thursday", date(2026, 1, 15)), - ] -``` - -Also assert: - -- Night-only and Day-only each call `print_document` once. -- Progress messages end at `1/1` or `2/2`, based on manifest length. -- Success says `All 2 selected schedules have been processed and sent to the printer.`, not the old day-based message. -- Cancellation set before the batch prints zero jobs. -- Cancellation set after the first job prevents the second. -- A failed Night job records `"shift": "night"` and does not prevent the remaining selected job. -- The saved `AppConfig` still contains both folder values and the printer. -- Reused preflight `WordProcessor` is cleared after acquisition. -- `_reset_ui` restores the count-aware manifest button through the UI. - -- [ ] **Step 2: Run worker tests and confirm RED** - -Run: - -```bash -.venv/bin/python -m pytest tests/test_main.py \ - -k "process_batch or cancel or failure or reset_ui" -q -``` - -Expected: failures because `_process_batch` still expects a dictionary, reconstructs two jobs per date, and reports days. - -- [ ] **Step 3: Replace `_print_shift` with concrete-job processing** - -Rename `_print_shift` to `_print_job` and use: - -```python -def _print_job( - self, - word_proc: WordProcessor, - job: PrintJob, - printer_name: str, - job_index: int, - total_jobs: int, - failed_operations: list[FailedOperation], -) -> None: -``` - -Derive `shift_label`, display text, Word arguments, logging, and failure fields from `job`. Keep progress as `(job_index + 1) / total_jobs`. - -- [ ] **Step 4: Iterate only the frozen request manifest** - -Change: - -```python -def _process_batch(self, request: _BatchRequest) -> None: -``` - -The worker must: - -- save `request.day_folder`, `request.night_folder`, and `request.printer_name`; -- set `total_jobs = len(request.manifest)`; -- acquire and clear the preflight WordProcessor; -- check cancellation immediately before every manifest job; -- call `_print_job` in tuple order; -- keep processing after an individual print failure; -- report `All {total_jobs} selected schedules have been processed and sent to the printer.`; -- keep existing CSV, warning, exception, and `finally` cleanup paths. - -Delete `_compute_batch_size` and all imports/usages of `get_date_range` and `get_shift_template_name` from `src/main.py` after the migration is complete. Template naming now belongs only to manifest construction. - -Change `_reset_ui` to: - -```python -self.ui.set_inputs_enabled(True) -self.ui.refresh_manifest_preview() -self.ui.set_print_button_state("normal") -``` - -The UI method restores the count-aware label and amber button styling. - -- [ ] **Step 5: Run all main, manifest, and scheduler tests** - -Run: - -```bash -.venv/bin/python -m pytest \ - tests/test_main.py tests/test_print_manifest.py tests/test_scheduler.py -q -``` - -Expected: all selected tests pass, with no references to `_compute_batch_size`, `get_start_date`, or `get_end_date`. - -- [ ] **Step 6: Commit manifest-driven execution** - -```bash -git add src/main.py src/ui.py tests/test_main.py -git commit -m "feat: print only selected shift jobs" -``` - ---- - -## Task 5: Render the Native UI and Close Automated Regressions - -**Files:** - -- Inspect and modify only if the render exposes a defect: `src/ui.py`, `src/constants.py`, `tests/test_ui.py` -- Create: `docs/windows-smoke-test.md` -- Modify: `README.md` - -- [ ] **Step 1: Run the full suite before visual adjustment** - -Run: - -```bash -.venv/bin/python -m pytest --cov=src --cov-report=term-missing -``` - -Expected: full suite passes. If an unrelated existing test fails, record it before changing code; do not hide it with skips or broad mocks. - -- [ ] **Step 2: Launch and inspect the real Tkinter window** - -Run: - -```bash -.venv/bin/python main.py -``` - -Inspect the rendered app at its default size and after narrowing/re-expanding: - -- Setup is compact and contains both paths plus Printer. -- Night and Day are equal-width and simultaneously visible. -- Night shows amber identity; Day shows blue identity; both have written names. -- Defaults are Night today and Day tomorrow. -- Include and mode changes affect only their own panel. -- Single/range values survive mode and include toggles. -- Manifest and button count update immediately. -- No control is clipped at the 980 × 820 initial size. -- Keyboard focus reaches controls in visual order. -- Disabled text remains readable against the charcoal surface. - -Make only evidence-based spacing, sizing, or state fixes. Add a regression test before each behavior change; visual-only spacing changes must be accompanied by a rerender. - -- [ ] **Step 3: Record the Windows operational checklist** - -Create `docs/windows-smoke-test.md` with: - -- prerequisites: Windows 10/11, Python 3.12 or the built executable, Word installed, test printer or PDF printer, known Day/Night templates; -- build identifier/commit field; -- tester/date/result fields; -- exact cases: - 1. Night today plus Day tomorrow prints exactly two documents in manifest order. - 2. Night-only prints one Night document without requiring a valid Day folder. - 3. Day-only prints one Day document without requiring a valid Night folder. - 4. Independent ranges print the exact ordered manifest count. - 5. A missing selected template stops before Word opens. - 6. Cancel stops before the next document and Word exits cleanly. - 7. A forced partial failure creates an accurate CSV and continues remaining jobs. - 8. Relaunch preserves folders/printer but restores fresh today/tomorrow defaults. - -Mark the checklist `Pending Windows execution` until it is actually performed. Do not fabricate results. - -Add a short README link under Testing or Quality Checks: - -```markdown -For Word COM and physical print verification, complete the -[Windows smoke test](docs/windows-smoke-test.md). -``` - -- [ ] **Step 4: Commit the rendered UI adjustments and checklist** - -```bash -git add src/ui.py src/constants.py tests/test_ui.py README.md docs/windows-smoke-test.md -git commit -m "docs: add Windows print smoke test" -``` - -If the render required no code or test adjustment, stage and commit only `README.md` and `docs/windows-smoke-test.md`. - ---- - -## Task 6: Run Final Quality Gates and Review the Diff - -**Files:** - -- Verify: all changed files - -- [ ] **Step 1: Format, then prove formatting is clean** - -Run: - -```bash -.venv/bin/python -m black src tests -.venv/bin/python -m black --check src tests -``` - -Expected: the check reports all files would be left unchanged. If Black rewrites files, rerun all affected tests before continuing. - -- [ ] **Step 2: Run repository quality gates** - -Run: - -```bash -.venv/bin/python -m mypy src --ignore-missing-imports -.venv/bin/python -m pytest --cov=src --cov-report=term-missing -.venv/bin/python -m pylint src --fail-under=8.0 -``` - -Expected: - -- mypy: success with no issues; -- pytest: all tests pass and coverage report completes; -- pylint: score is at least 8.0. - -- [ ] **Step 3: Run focused selection regressions once more** - -Run: - -```bash -.venv/bin/python -m pytest \ - tests/test_print_manifest.py tests/test_ui.py tests/test_main.py -q -``` - -Expected: all independent Night/Day, disabled-shift, manifest-order, cancellation, and failure-report tests pass. - -- [ ] **Step 4: Self-review against the approved specification** - -Run: - -```bash -git diff --check -git status --short -git diff origin/test...HEAD -- \ - src tests README.md docs/windows-smoke-test.md \ - docs/superpowers/specs/2026-07-30-independent-shift-printing-design.md -rg -n "TO[D]O|T[B]D|FIX[M]E|days x 2|days × 2|get_start_date|get_end_date|_compute_batch_size" \ - src tests README.md docs/windows-smoke-test.md -``` - -Expected: - -- no whitespace errors; -- only intended feature/doc files differ; -- no implementation placeholders; -- no old shared-range or two-jobs-per-day assumptions remain; -- design spec, tests, UI getters, controller request types, and worker types agree. - -- [ ] **Step 5: Commit any quality-tool rewrites** - -If formatting or review produced tracked fixes: - -```bash -git add src tests README.md docs/windows-smoke-test.md -git commit -m "test: close independent shift regressions" -``` - -If nothing changed, do not create an empty commit. - -- [ ] **Step 6: Report the verified boundary** - -Report: - -- exact local commit hash and branch; -- commands and pass counts for Black, mypy, pytest/coverage, and pylint; -- native render result; -- that Night and Day automated behavior is verified; -- whether the Windows checklist remains pending or has real tester/date/build evidence; -- explicitly that Word COM and physical printer output are not end-to-end verified if the Windows checklist is pending. diff --git a/docs/superpowers/plans/2026-07-31-review-defect-remediation.md b/docs/superpowers/plans/2026-07-31-review-defect-remediation.md deleted file mode 100644 index acab4c7..0000000 --- a/docs/superpowers/plans/2026-07-31-review-defect-remediation.md +++ /dev/null @@ -1,1563 +0,0 @@ -# Review Defect Remediation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix the fifteen defects found in the ShiftPress frontend design and backend functionality review, without regressing the existing 219-test suite or the quality gates. - -**Architecture:** Nine independently reviewable tasks. Backend correctness first (template collisions, COM retries, dead constants), then a pure-domain validation method that the UI work depends on, then the three UI tasks, then CI, then docs and copy. Each task is test-driven and ends in a commit. - -**Tech Stack:** Python 3.12, Tkinter/ttk, tkcalendar, pywin32 (Word COM), pytest + pytest-cov, black, mypy, pylint, PyInstaller, GitHub Actions. - -## Global Constraints - -- Spec: `docs/superpowers/specs/2026-07-31-review-defect-remediation-design.md`. Read it before starting. -- Work directly on the current branch (`test`). No worktree. -- All 219 existing tests must stay green. Run the full suite before every commit. -- `black --check src tests` must stay clean. Run `black src tests` before committing. -- `mypy src` must report no issues. -- `pylint src --fail-under=8.0` must pass. Current score is 8.85; do not regress below 8.0. -- Python 3.12 is required. The system `python3` on this host is 3.9 and will not work. Use a 3.12 virtualenv. -- Tests mock all Windows-only modules (`win32print`, `pythoncom`, `win32com`, `win32com.client`, `tkcalendar`) in `tests/conftest.py`. Never import them unguarded at module scope. -- `tests/test_ui.py` patches every `ttk` widget class and uses `MagicMock(spec=tk.Tk)` as root. Real geometry is unavailable there; `winfo_reqheight()` returns a `MagicMock`, not an integer. -- Colour tokens are the frozen dataclass in `src/constants.py`. Use `COLORS.`, never a raw hex literal. -- Copy uses sentence case and operational nouns per `DESIGN.md` ("The Plain Label Rule"). - -### Environment setup - -Run once before Task 1: - -```bash -/opt/homebrew/bin/python3.12 -m venv .venv312 && .venv312/bin/pip install -q --upgrade pip && .venv312/bin/pip install -q -r requirements-dev.txt -``` - -All `pytest`, `black`, `mypy`, `pylint` commands below assume `.venv312/bin/` on the path. Add `.venv312/` to `.gitignore` in Task 1 if it is not already ignored. - ---- - -### Task 1: Template collision detection - -Two template files whose stems normalize identically (e.g. `Thursday Night.docx` and `Thursday Night.docx` with a double space) currently collapse into one cache key. The survivor depends on directory iteration order, and the wrong schedule prints with no error and no warning. - -**Files:** -- Modify: `src/word_processor.py:79` (cache type annotation), `src/word_processor.py:176-199` (`_build_template_cache`), `src/word_processor.py:270-368` (`find_template_file`) -- Test: `tests/test_word_processor.py` - -**Interfaces:** -- Consumes: nothing from earlier tasks. -- Produces: `WordProcessor._build_template_cache(folder_path: str) -> dict[str, list[str]]`; `WordProcessor._template_cache: dict[str, dict[str, list[str]]]`; `WordProcessor._resolve_unique(base_name: str, paths: list[str]) -> str` raising `TemplateLookupError`. `find_template_file(folder: str, template_name: str) -> Optional[str]` keeps its existing signature and return type. - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_word_processor.py` inside `class TestWordProcessor`: - -```python - def test_colliding_normalized_names_raise(self, wp, tmp_path): - """Two files that normalize to the same name must not silently shadow.""" - (tmp_path / "Thursday Night.docx").write_text("dummy") - (tmp_path / "Thursday Night.docx").write_text("dummy") - - with pytest.raises(TemplateLookupError) as exc: - wp.find_template_file(str(tmp_path), "Thursday Night") - - message = str(exc.value) - assert "Thursday Night.docx" in message - assert "Thursday Night.docx" in message - - def test_collision_check_does_not_break_normal_lookup(self, wp, tmp_path): - """A folder without collisions must resolve exactly as before.""" - (tmp_path / "Monday.docx").write_text("dummy") - (tmp_path / "Thursday Night.docx").write_text("dummy") - (tmp_path / "THIRD Thursday.docx").write_text("dummy") - - assert wp.find_template_file(str(tmp_path), "Monday").endswith("Monday.docx") - assert wp.find_template_file(str(tmp_path), "Thursday").endswith( - "Thursday Night.docx" - ) - assert wp.find_template_file(str(tmp_path), "THIRD Thursday").endswith( - "THIRD Thursday.docx" - ) -``` - -Update the import at the top of the file from `from src.word_processor import WordProcessor` to: - -```python -from src.word_processor import WordProcessor, TemplateLookupError -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `.venv312/bin/pytest tests/test_word_processor.py -k "collid or collision" -v` -Expected: `test_colliding_normalized_names_raise` FAILS with `DID NOT RAISE `. - -- [ ] **Step 3: Change the cache to hold every colliding path** - -In `src/word_processor.py`, change the attribute annotation in `__init__`: - -```python - self._template_cache: dict[str, dict[str, list[str]]] = {} -``` - -Replace `_build_template_cache` entirely: - -```python - def _build_template_cache(self, folder_path: str) -> dict[str, list[str]]: - """Build a normalized template cache for a folder. - - Scans *folder_path* for ``.docx`` files, filtering out Word lock - files (``~$*``) and hidden files (``.*``). - - Args: - folder_path: Absolute path to the template folder. - - Returns: - Dict mapping normalized (lower-cased, whitespace-collapsed) base - names to every file path that normalizes to that name. A name - with more than one path is a collision and is rejected at lookup - time rather than silently resolved. - """ - - cache: dict[str, list[str]] = {} - for entry in Path(folder_path).iterdir(): - name = entry.name - # Skip Word temp lock files and hidden files - if name.startswith("~$") or name.startswith("."): - continue - if name.lower().endswith(DOCX_EXTENSION): - base_name = " ".join(entry.stem.lower().split()) - cache.setdefault(base_name, []).append(str(entry)) - for paths in cache.values(): - paths.sort() - return cache -``` - -- [ ] **Step 4: Add the collision guard** - -Add this method to `WordProcessor`, directly above `find_template_file`: - -```python - @staticmethod - def _resolve_unique(base_name: str, paths: list[str]) -> str: - """Return the single path for *base_name*, or reject the collision. - - Args: - base_name: The normalized template name that was matched. - paths: Every file path that normalizes to *base_name*. - - Returns: - The one matching file path. - - Raises: - TemplateLookupError: If more than one file normalizes to the - same name, which would otherwise print an arbitrary file. - """ - if len(paths) > 1: - names = ", ".join(sorted(Path(p).name for p in paths)) - raise TemplateLookupError( - f"Multiple template files resolve to the same name " - f"'{base_name}': {names}. Rename templates to be unique." - ) - return paths[0] -``` - -- [ ] **Step 5: Route every lookup branch through the guard** - -In `find_template_file`, replace the body of the `for attempt in range(2):` loop (currently `src/word_processor.py:299-365`) with: - -```python - for attempt in range(2): - cache = self._template_cache[folder_path] - - # 1. Try exact match - if template_name_lower in cache: - target = self._resolve_unique( - template_name_lower, cache[template_name_lower] - ) - logger.debug(f"Template exact match: '{template_name}' -> {target}") - return target - - # 2. Try robust matching using word boundaries - # This prevents "Thursday" matching "THIRD Thursday" - # but allows "Thursday" matching "Thursday Night" if it's the only match - pattern = re.compile(rf"\b{re.escape(template_name_lower)}\b") - - matched_keys: list[str] = [] - for base_name in cache: - if pattern.search(base_name): - # Special logic: if search term doesn't have "third" but filename does, skip - # This prevents "Thursday" matching "THIRD Thursday" - if "third" not in template_name_lower and "third" in base_name: - continue - matched_keys.append(base_name) - - if len(matched_keys) == 1: - key = matched_keys[0] - target = self._resolve_unique(key, cache[key]) - logger.info(f"Found robust template match: {target}") - return target - if len(matched_keys) > 1: - # Prefer the most specific key: an exact stem, then a prefix. - starts = [k for k in matched_keys if k.startswith(template_name_lower)] - if len(starts) == 1: - target = self._resolve_unique(starts[0], cache[starts[0]]) - logger.info(f"Found specific template match: {target}") - return target - - candidates = sorted( - path for key in matched_keys for path in cache[key] - ) - raise TemplateLookupError( - f"Ambiguous template matches for '{template_name}'. " - f"Please rename templates to be unique. Matches: {candidates}" - ) - - # Not found: refresh once in case templates were added during runtime. - # Only refresh if we had a pre-existing cache; if we just built the cache, - # refreshing again is unlikely to help and only adds I/O. - if attempt == 0 and had_cache: - logger.debug( - f"Template not found; refreshing cache for {folder_path} and retrying" - ) - self._ensure_template_cache(folder_path, force_refresh=True) - continue - - logger.warning(f"Template not found: {template_name} in {folder}") - return None -``` - -Note: the old exact-stem branch is now redundant, because an exact stem equals the cache key and is already handled by branch 1. The prefix branch is retained. - -- [ ] **Step 6: Run the full suite** - -Run: `.venv312/bin/pytest -q` -Expected: PASS, 221 passed. - -- [ ] **Step 7: Run the quality gates** - -Run: -```bash -.venv312/bin/black src tests && .venv312/bin/mypy src && .venv312/bin/pylint src --fail-under=8.0 -``` -Expected: black reformats nothing or only the files you touched; mypy reports success; pylint scores at or above 8.0. - -- [ ] **Step 8: Commit** - -```bash -git add src/word_processor.py tests/test_word_processor.py -git commit -m "fix: reject template names that collide after normalization" -``` - ---- - -### Task 2: COM retry precision - -`safe_com_call` treats the bare substring `"server"` as transient, so a permanent fault like "The server threw an exception" burns five retries at one second each, per document. - -**Files:** -- Modify: `src/word_processor.py:256` (the `transient_keywords` tuple) -- Test: `tests/test_word_processor.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: no signature change. `safe_com_call(func, *args, retries=COM_RETRIES, delay=COM_RETRY_DELAY)` behaves identically except for which error strings it considers transient. - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_word_processor.py` inside `class TestWordProcessor`: - -```python - def test_permanent_server_error_is_not_retried(self, wp): - """A permanent COM fault must fail fast instead of burning retries.""" - call = MagicMock(side_effect=Exception("The server threw an exception")) - - with pytest.raises(Exception, match="threw an exception"): - wp.safe_com_call(call, retries=3, delay=0) - - assert call.call_count == 1 - - def test_busy_server_error_is_retried(self, wp): - """A genuinely transient COM fault must still be retried.""" - call = MagicMock( - side_effect=[Exception("Server is busy"), Exception("Server is busy"), "ok"] - ) - - assert wp.safe_com_call(call, retries=3, delay=0) == "ok" - assert call.call_count == 3 -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `.venv312/bin/pytest tests/test_word_processor.py -k "server" -v` -Expected: `test_permanent_server_error_is_not_retried` FAILS with `assert 3 == 1`. - -- [ ] **Step 3: Narrow the transient keyword list** - -In `src/word_processor.py`, inside `safe_com_call`, replace: - -```python - transient_keywords = ("rejected", "call was rejected", "busy", "server") -``` - -with: - -```python - # Specific transient COM markers only. A bare "server" match - # would also catch permanent faults such as "The server threw - # an exception", costing retries * delay seconds per document. - transient_keywords = ( - "call was rejected", - "rejected by callee", - "server is busy", - "message filter", - "rpc_e_", - ) -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `.venv312/bin/pytest tests/test_word_processor.py -k "server or com_call" -v` -Expected: PASS. - -- [ ] **Step 5: Run the full suite and gates** - -Run: -```bash -.venv312/bin/pytest -q && .venv312/bin/black src tests && .venv312/bin/mypy src && .venv312/bin/pylint src --fail-under=8.0 -``` -Expected: 223 passed; all gates clean. - -If an existing test asserted retry behaviour using the word "server" alone, update it to use "Server is busy". - -- [ ] **Step 6: Commit** - -```bash -git add src/word_processor.py tests/test_word_processor.py -git commit -m "fix: retry only genuinely transient COM faults" -``` - ---- - -### Task 3: Remove dead Word story constants - -Six `WD_*_STORY` constants are defined and exported but imported nowhere. They are fossils of a removed header/footer-only mode. - -**Files:** -- Modify: `src/constants.py:39-44` (`__all__` entries), `src/constants.py:126-133` (definitions and comment) - -**Interfaces:** -- Consumes: nothing. -- Produces: `WD_FIND_CONTINUE` and `WD_REPLACE_ALL` remain exported and unchanged. The six story constants no longer exist. - -- [ ] **Step 1: Confirm they are unused** - -Run: `grep -rn "STORY" src/ tests/ | grep -v "WD_FIND\|WD_REPLACE"` -Expected: only the definition and `__all__` lines in `src/constants.py`. If anything else appears, stop and report it. - -- [ ] **Step 2: Delete the `__all__` entries** - -In `src/constants.py`, remove these six lines from the `__all__` list: - -```python - "WD_PRIMARY_HEADER_STORY", - "WD_EVEN_PAGES_HEADER_STORY", - "WD_PRIMARY_FOOTER_STORY", - "WD_EVEN_PAGES_FOOTER_STORY", - "WD_FIRST_PAGE_HEADER_STORY", - "WD_FIRST_PAGE_FOOTER_STORY", -``` - -- [ ] **Step 3: Delete the definitions** - -In `src/constants.py`, remove this block entirely: - -```python -# Word story types (used to target header/footer-only replacements) -# https://learn.microsoft.com/en-us/office/vba/api/word.wdstorytype -WD_EVEN_PAGES_HEADER_STORY: Final = 6 # wdEvenPagesHeaderStory -WD_PRIMARY_HEADER_STORY: Final = 7 # wdPrimaryHeaderStory -WD_EVEN_PAGES_FOOTER_STORY: Final = 8 # wdEvenPagesFooterStory -WD_PRIMARY_FOOTER_STORY: Final = 9 # wdPrimaryFooterStory -WD_FIRST_PAGE_HEADER_STORY: Final = 10 # wdFirstPageHeaderStory -WD_FIRST_PAGE_FOOTER_STORY: Final = 11 # wdFirstPageFooterStory -``` - -Leave the `# Word Find/Replace constants` block that follows it intact. - -- [ ] **Step 4: Run the full suite and gates** - -Run: -```bash -.venv312/bin/pytest -q && .venv312/bin/black src tests && .venv312/bin/mypy src && .venv312/bin/pylint src --fail-under=8.0 -``` -Expected: 223 passed; all gates clean. Any `ImportError` means Step 1 missed a reference. - -- [ ] **Step 5: Commit** - -```bash -git add src/constants.py -git commit -m "refactor: drop unused Word story constants" -``` - ---- - -### Task 4: Shift-labelled selection validation - -`validate_date_range` produces "End date cannot be before start date" with no shift label, and the UI has no way to ask "is this one shift valid?" without building the whole manifest. This task adds the pure domain method that Task 7 and `_validate_inputs` both consume. - -**Files:** -- Modify: `src/print_manifest.py:7` (import), `src/print_manifest.py:21-43` (`ShiftSelection`) -- Test: `tests/test_print_manifest.py` - -**Interfaces:** -- Consumes: `validate_date_range(start: date, end: date) -> tuple[bool, Optional[str]]` from `src/scheduler.py`. -- Produces: `ShiftSelection.validate() -> Optional[str]`. Returns `None` when the selection is valid or disabled; otherwise a shift-labelled human-readable message. Task 7 and Task 9 rely on this exact name and return type. - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_print_manifest.py`: - -```python -def test_validate_accepts_a_valid_single_date() -> None: - """A well-formed single-date selection has no error.""" - assert _selection("night").validate() is None - - -def test_validate_ignores_a_disabled_selection() -> None: - """A shift that is not included cannot be invalid.""" - selection = _selection( - "day", - enabled=False, - mode="range", - start_date=date(2026, 8, 10), - end_date=date(2026, 8, 1), - ) - - assert selection.validate() is None - - -def test_validate_labels_a_reversed_range_with_its_shift() -> None: - """A reversed range must name the shift it belongs to.""" - selection = _selection( - "night", - mode="range", - start_date=date(2026, 8, 10), - end_date=date(2026, 8, 1), - ) - - error = selection.validate() - - assert error is not None - assert error.startswith("Night schedule:") - assert "End date cannot be before start date" in error - - -def test_validate_labels_a_missing_date_with_its_shift() -> None: - """A missing date must name the shift it belongs to.""" - selection = _selection("day", start_date=None, end_date=None) - - assert selection.validate() == "Select a Day date" -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `.venv312/bin/pytest tests/test_print_manifest.py -k validate -v` -Expected: FAIL with `AttributeError: 'ShiftSelection' object has no attribute 'validate'`. - -- [ ] **Step 3: Import the range validator** - -In `src/print_manifest.py`, change: - -```python -from .scheduler import get_date_range, get_shift_template_name -``` - -to: - -```python -from .scheduler import get_date_range, get_shift_template_name, validate_date_range -``` - -- [ ] **Step 4: Add the method** - -Add to `ShiftSelection`, directly below `active_range`: - -```python - def validate(self) -> Optional[str]: - """Return a shift-labelled error for this selection, or None if valid. - - A disabled selection is always valid: an excluded shift contributes - no jobs, so its date values cannot block a run. - - Returns: - ``None`` when this selection can contribute jobs, otherwise a - human-readable message naming the shift at fault. - """ - if not self.enabled: - return None - label = self.shift_type.title() - try: - start_date, end_date = self.active_range() - except ValueError as e: - return str(e) - is_valid, error_msg = validate_date_range(start_date, end_date) - if not is_valid: - return f"{label} schedule: {error_msg}" - return None -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `.venv312/bin/pytest tests/test_print_manifest.py -v` -Expected: PASS, 14 passed (10 existing + 4 new). - -- [ ] **Step 6: Run the full suite and gates** - -Run: -```bash -.venv312/bin/pytest -q && .venv312/bin/black src tests && .venv312/bin/mypy src && .venv312/bin/pylint src --fail-under=8.0 -``` -Expected: 227 passed; all gates clean. - -- [ ] **Step 7: Commit** - -```bash -git add src/print_manifest.py tests/test_print_manifest.py -git commit -m "feat: add shift-labelled selection validation" -``` - ---- - -### Task 5: Equal-height date modes and content-derived window size - -The single-date row is a label beside an entry (34px); the range rows are labels above entries (60px). Toggling modes changes layout height by 26px and the window never re-fits, and `minsize` (720) is below the required height (759), so the Print button can be clipped by 37 of its 61 pixels. - -**Files:** -- Modify: `src/constants.py:32-34,52` (`__all__`), `src/constants.py:99-101,117-120` (definitions), `src/ui.py:28-41` (imports), `src/ui.py:199-214` (`__init__` sizing), `src/ui.py:246-253` (post-construction sizing), `src/ui.py:930-942` (single-date row), `src/ui.py:1121-1139` (`_auto_resize_to_content`) -- Test: `tests/test_ui.py` - -**Interfaces:** -- Consumes: nothing from earlier tasks. -- Produces: `ScheduleAppUI._apply_content_sizing() -> None`, replacing `_auto_resize_to_content`. `WINDOW_HEIGHT` and `WINDOW_MIN_HEIGHT` no longer exist in `src/constants.py`. `WINDOW_WIDTH`, `AUTO_RESIZE_MIN_WIDTH`, `AUTO_RESIZE_MIN_HEIGHT` remain. - -- [ ] **Step 1: Expose the patched Label class to tests** - -In `tests/test_ui.py`, in the `ui` fixture, change: - -```python - ), patch("src.ui.ttk.Label"), patch( -``` - -to: - -```python - ), patch("src.ui.ttk.Label") as MockLabel, patch( -``` - -and add this line beside the other `_test_*` assignments: - -```python - ui._test_label_class = MockLabel -``` - -- [ ] **Step 2: Write the failing tests** - -Add to `tests/test_ui.py` inside `class TestScheduleAppUI`: - -```python - def test_single_date_row_matches_range_row_structure(self, ui): - """Both date modes must build the same label-above-entry shape.""" - panel = ui._shift_panels["night"] - - panel.single_picker.pack.assert_any_call(fill="x") - panel.range_start_picker.pack.assert_any_call(fill="x") - panel.range_end_picker.pack.assert_any_call(fill="x") - assert panel.single_picker.grid.call_count == 0 - - date_labels = [ - call - for call in ui._test_label_class.call_args_list - if call.kwargs.get("text") == "Date" - ] - assert len(date_labels) == 2 # one per shift panel - assert date_labels[0].kwargs["style"] == "CardSub.TLabel" - - def test_window_sizing_derives_from_rendered_content(self, ui, root): - """minsize and geometry must come from content, not a hardcoded guess.""" - root.winfo_reqheight.return_value = 812 - root.winfo_reqwidth.return_value = 1000 - root.winfo_screenwidth.return_value = 1920 - root.winfo_screenheight.return_value = 1080 - root.minsize.reset_mock() - root.geometry.reset_mock() - - ui._apply_content_sizing() - - root.minsize.assert_called_once_with(1040, 812) - root.geometry.assert_called_once_with("1040x812") -``` - -- [ ] **Step 3: Run the tests to verify they fail** - -Run: `.venv312/bin/pytest tests/test_ui.py -k "single_date_row or window_sizing" -v` -Expected: both FAIL — the first with `assert 1 == 0` on `single_picker.grid.call_count`, the second with `AttributeError: 'ScheduleAppUI' object has no attribute '_apply_content_sizing'`. - -- [ ] **Step 4: Restructure the single-date row** - -In `src/ui.py`, inside `_create_shift_panel`, replace: - -```python - single_wrap = ttk.Frame(date_stack, style="Card.TFrame") - single_wrap.grid(row=0, column=0, sticky="ew") - single_wrap.grid_columnconfigure(1, weight=1) - ttk.Label(single_wrap, text="Date:", style="Card.TLabel").grid( - row=0, column=0, sticky="w", padx=(0, 14) - ) - single_picker = self._create_date_entry( - date_entry_cls, - single_wrap, - calendar_kw=self._calendar_kwargs(), - ) - single_picker.grid(row=0, column=1, sticky="ew") - single_picker.set_date(default_date) -``` - -with: - -```python - # Label-above-entry mirrors the range rows so both modes occupy the - # same height and toggling never reflows the window. - single_wrap = ttk.Frame(date_stack, style="Card.TFrame") - single_wrap.grid(row=0, column=0, sticky="ew") - ttk.Label(single_wrap, text="Date", style="CardSub.TLabel").pack( - anchor="w", pady=(0, 6) - ) - single_picker = self._create_date_entry( - date_entry_cls, - single_wrap, - calendar_kw=self._calendar_kwargs(), - ) - single_picker.pack(fill="x") - single_picker.set_date(default_date) -``` - -- [ ] **Step 5: Replace the sizing logic** - -In `src/ui.py`, replace the whole `_auto_resize_to_content` method with: - -```python - def _apply_content_sizing(self) -> None: - """Size the window from rendered content so it cannot clip itself. - - Deriving both the initial geometry and the minimum size from Tk's - computed requirement keeps the primary action visible under any - Windows text-scaling setting, which a hardcoded height cannot. - Both date modes are the same height by construction, so the - requirement does not change after launch. - """ - - try: - self.root.update_idletasks() - req_w = self.root.winfo_reqwidth() - req_h = self.root.winfo_reqheight() - scr_w = self.root.winfo_screenwidth() - scr_h = self.root.winfo_screenheight() - - target_w = min( - max(WINDOW_WIDTH, req_w), max(AUTO_RESIZE_MIN_WIDTH, scr_w - 80) - ) - target_h = min( - max(AUTO_RESIZE_MIN_HEIGHT, req_h), - max(AUTO_RESIZE_MIN_HEIGHT, scr_h - 80), - ) - - self.root.minsize(target_w, target_h) - self.root.geometry(f"{target_w}x{target_h}") - except Exception as e: - logger.debug(f"Content sizing skipped: {e}") -``` - -- [ ] **Step 6: Rewire construction** - -In `src/ui.py` `__init__`, delete these two statements: - -```python - self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}") -``` - -and - -```python - # Provide a sane minimum size; DPI scaling can otherwise clip content. - try: - self.root.minsize(WINDOW_WIDTH, WINDOW_MIN_HEIGHT) - except Exception as e: - logger.debug(f"Could not set minimum window size: {e}") -``` - -Then replace: - -```python - # If DPI scaling / fonts push content beyond default height, expand once. - self._auto_resize_to_content() -``` - -with: - -```python - # Derive geometry and minimum size from the rendered content. - self._apply_content_sizing() -``` - -Finally remove `WINDOW_HEIGHT` and `WINDOW_MIN_HEIGHT` from the `from .constants import (...)` block at the top of `src/ui.py`. - -- [ ] **Step 7: Remove the now-unused constants** - -In `src/constants.py`, delete `"WINDOW_HEIGHT",` and `"WINDOW_MIN_HEIGHT",` from `__all__`, and delete these lines: - -```python -WINDOW_HEIGHT: Final = 720 -``` - -```python -WINDOW_MIN_HEIGHT: Final = 720 -``` - -Keep `WINDOW_WIDTH`, `WINDOW_RESIZABLE`, `AUTO_RESIZE_MIN_WIDTH`, and `AUTO_RESIZE_MIN_HEIGHT`. - -- [ ] **Step 8: Run the tests to verify they pass** - -Run: `.venv312/bin/pytest tests/test_ui.py -v` -Expected: PASS. - -- [ ] **Step 9: Verify against real Tk** - -Create `/tmp/measure.py`: - -```python -import sys, tkinter as tk -from datetime import date -sys.path.insert(0, ".") -from src.ui import ScheduleAppUI - -root = tk.Tk() -ui = ScheduleAppUI(root, today=date(2026, 7, 31)) -root.update_idletasks() -p = ui._shift_panels["night"] -single_h = p.single_wrap.winfo_reqheight() -p.mode_var.set("range"); ui._sync_shift_panel_state("night") -root.update_idletasks() -range_h = p.range_wrap.winfo_reqheight() -print(f"single={single_h} range={range_h} equal={single_h == range_h}") -print(f"minsize={root.minsize()} reqheight={root.winfo_reqheight()}") -root.destroy() -``` - -Run: `.venv312/bin/python /tmp/measure.py` -Expected: `equal=True`, and the `minsize` height equal to `reqheight`. - -- [ ] **Step 10: Run the full suite and gates** - -Run: -```bash -.venv312/bin/pytest -q && .venv312/bin/black src tests && .venv312/bin/mypy src && .venv312/bin/pylint src --fail-under=8.0 -``` -Expected: 229 passed; all gates clean. - -- [ ] **Step 11: Commit** - -```bash -git add src/ui.py src/constants.py tests/test_ui.py -git commit -m "fix: size window from content and equalize date mode heights" -``` - ---- - -### Task 6: Folder-path setters own the placeholder contract - -`ShiftPressApp._load_config` calls `.delete()` / `.insert()` on entry widgets it does not own, bypassing the placeholder's colour contract, so a saved path renders in placeholder gray (`#B5B7BD`) instead of primary text (`#F4F4F5`). - -**Files:** -- Modify: `src/ui.py:1304-1322` (`_browse_folder`), `src/ui.py` (new setters near the public getters), `src/main.py:125-143` (`_load_config`) -- Test: `tests/test_ui.py`, `tests/test_main.py:559-579` - -**Interfaces:** -- Consumes: `_PATH_PLACEHOLDER` (module constant, already present in `src/ui.py`). -- Produces: `ScheduleAppUI.set_day_folder(path: str) -> None` and `ScheduleAppUI.set_night_folder(path: str) -> None`. - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_ui.py` inside `class TestScheduleAppUI`: - -```python - def test_set_day_folder_uses_primary_text_color(self, ui): - """A real saved path must not render as placeholder gray.""" - ui.set_day_folder("C:/Templates/Day") - - ui.day_entry.config.assert_called_with(foreground=COLORS.text_main) - ui.day_entry.insert.assert_called_with(0, "C:/Templates/Day") - - def test_set_night_folder_empty_restores_placeholder(self, ui): - """An empty saved path must fall back to the dim placeholder.""" - ui.set_night_folder("") - - ui.night_entry.config.assert_called_with(foreground=COLORS.text_dim) - ui.night_entry.insert.assert_called_with(0, _PATH_PLACEHOLDER) -``` - -Update the `src.ui` import at the top of `tests/test_ui.py`: - -```python -from src.ui import ScheduleAppUI, _PATH_PLACEHOLDER -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `.venv312/bin/pytest tests/test_ui.py -k "set_day_folder or set_night_folder" -v` -Expected: FAIL with `AttributeError: 'ScheduleAppUI' object has no attribute 'set_day_folder'`. - -- [ ] **Step 3: Add the setters** - -In `src/ui.py`, add these three methods immediately above `get_day_folder`, under the `# Public getters` divider: - -```python - def _set_folder_entry(self, entry: Optional[ttk.Entry], path: str) -> None: - """Write one path entry, honoring the placeholder and colour contract. - - Args: - entry: The target entry widget, or ``None`` if not yet built. - path: The folder path to show. Empty restores the placeholder. - """ - if entry is None: - return - value = (path or "").strip() - entry.delete(0, tk.END) - if value: - entry.config(foreground=COLORS.text_main) - entry.insert(0, value) - else: - entry.config(foreground=COLORS.text_dim) - entry.insert(0, _PATH_PLACEHOLDER) - - def set_day_folder(self, path: str) -> None: - """Set the Day templates folder shown in Setup.""" - self._set_folder_entry(self.day_entry, path) - - def set_night_folder(self, path: str) -> None: - """Set the Night templates folder shown in Setup.""" - self._set_folder_entry(self.night_entry, path) -``` - -- [ ] **Step 4: Route browse through the same contract** - -In `src/ui.py` `_browse_folder`, replace: - -```python - if path: - entry.delete(0, tk.END) - entry.config(foreground=COLORS.text_main) - entry.insert(0, path) - logger.debug(f"Selected folder: {path}") - self.refresh_setup_summary() -``` - -with: - -```python - if path: - self._set_folder_entry(entry, path) - logger.debug(f"Selected folder: {path}") - self.refresh_setup_summary() -``` - -- [ ] **Step 5: Use the setters from the controller** - -In `src/main.py` `_load_config`, replace: - -```python - config = self.config_manager.load() - if config.day_folder and self.ui.day_entry: - self.ui.day_entry.delete(0, tk.END) - self.ui.day_entry.insert(0, config.day_folder) - if config.night_folder and self.ui.night_entry: - self.ui.night_entry.delete(0, tk.END) - self.ui.night_entry.insert(0, config.night_folder) - if config.printer_name and self.ui.printer_var: -``` - -with: - -```python - config = self.config_manager.load() - self.ui.set_day_folder(config.day_folder) - self.ui.set_night_folder(config.night_folder) - if config.printer_name and self.ui.printer_var: -``` - -If `tkinter as tk` becomes unused in `src/main.py` after this change, leave the import: it is still used for `tk.Tk` type hints and `tk.TclError` in `_safe_after`. - -- [ ] **Step 6: Update the existing controller test** - -In `tests/test_main.py`, in `test_load_config_populates_entries`, replace: - -```python - app.ui.day_entry.insert.assert_called_with(0, "/saved/day") - app.ui.night_entry.insert.assert_called_with(0, "/saved/night") -``` - -with: - -```python - app.ui.set_day_folder.assert_called_with("/saved/day") - app.ui.set_night_folder.assert_called_with("/saved/night") -``` - -- [ ] **Step 7: Run the full suite and gates** - -Run: -```bash -.venv312/bin/pytest -q && .venv312/bin/black src tests && .venv312/bin/mypy src && .venv312/bin/pylint src --fail-under=8.0 -``` -Expected: 231 passed; all gates clean. - -- [ ] **Step 8: Commit** - -```bash -git add src/ui.py src/main.py tests/test_ui.py tests/test_main.py -git commit -m "fix: render saved template paths as entered text" -``` - ---- - -### Task 7: Manifest pluralization, semantic state styles, per-shift error attribution - -Three defects share one function. The manifest title hardcodes the plural ("This run: 1 schedules"); the two count-label styles are byte-identical and always success green, so an error renders as success; and one shift's bad date flags both shifts with a message that names neither. - -**Files:** -- Modify: `src/ui.py:44-49` (imports), `src/ui.py:464-475` (count styles), `src/ui.py:976-980` (count label creation), `src/ui.py:1405-1475` (`refresh_manifest_preview`) -- Test: `tests/test_ui.py:218-256` (update two existing tests), plus new tests - -**Interfaces:** -- Consumes: `ShiftSelection.validate() -> Optional[str]` from Task 4. -- Produces: styles `CountReady.TLabel`, `CountMuted.TLabel`, `CountError.TLabel`. Helper methods `ScheduleAppUI._set_count(shift_type, text, style) -> None`, `_document_noun(count) -> str`, `_print_button_text(count) -> str`, `_describe_manifest(selections, manifest) -> tuple[str, list[str]]`, `_refresh_shift_counts(selections, errors) -> None`. - -- [ ] **Step 1: Update the two existing tests that pin the old behaviour** - -In `tests/test_ui.py`, in `test_manifest_preview_uses_actual_selected_job_count`, replace: - -```python - ui._shift_panels["night"].count_label.config.assert_called_with( - text="Selected · 1 document" - ) - ui._shift_panels["day"].count_label.config.assert_called_with( - text="Selected · 1 document" - ) -``` - -with: - -```python - ui._shift_panels["night"].count_label.config.assert_called_with( - text="Selected · 1 document", style="CountReady.TLabel" - ) - ui._shift_panels["day"].count_label.config.assert_called_with( - text="Selected · 1 document", style="CountReady.TLabel" - ) -``` - -- [ ] **Step 2: Write the failing tests** - -Add to `tests/test_ui.py` inside `class TestScheduleAppUI`: - -```python - def test_single_schedule_manifest_reads_as_singular(self, ui): - """A one-document run must not read 'This run: 1 schedules'.""" - ui._shift_panels["day"].enabled_var.set(False) - ui.manifest_title_label = MagicMock() - ui.manifest_label = MagicMock() - ui.print_btn = MagicMock() - - ui.refresh_manifest_preview() - - assert ( - ui.manifest_title_label.config.call_args.kwargs["text"] - == "This run: 1 schedule" - ) - ui.print_btn.config.assert_called_with(text="Print 1 schedule") - - def test_excluded_shift_uses_muted_state_not_success(self, ui): - """An excluded shift must not render in success green.""" - ui._shift_panels["day"].enabled_var.set(False) - ui.manifest_title_label = MagicMock() - ui.manifest_label = MagicMock() - ui.print_btn = MagicMock() - - ui.refresh_manifest_preview() - - ui._shift_panels["day"].count_label.config.assert_called_with( - text="Not included", style="CountMuted.TLabel" - ) - - def test_invalid_night_range_does_not_flag_valid_day(self, ui): - """One shift's bad dates must not accuse the other shift.""" - night = ui._shift_panels["night"] - night.mode_var.set("range") - night.range_start_picker.set_date(date(2026, 8, 10)) - night.range_end_picker.set_date(date(2026, 8, 1)) - ui.manifest_title_label = MagicMock() - ui.manifest_label = MagicMock() - ui.print_btn = MagicMock() - - ui.refresh_manifest_preview() - - night.count_label.config.assert_called_with( - text="Check Night date selection", style="CountError.TLabel" - ) - ui._shift_panels["day"].count_label.config.assert_called_with( - text="Selected · 1 document", style="CountReady.TLabel" - ) - title = ui.manifest_title_label.config.call_args.kwargs["text"] - assert title == "This run: Check Night date selection" - body = ui.manifest_label.config.call_args.kwargs["text"] - assert "Night schedule: End date cannot be before start date" in body - ui.print_btn.config.assert_called_with(text="Print schedules") -``` - -- [ ] **Step 3: Run the tests to verify they fail** - -Run: `.venv312/bin/pytest tests/test_ui.py -k "singular or muted_state or invalid_night" -v` -Expected: all three FAIL — the first on `'This run: 1 schedules' != 'This run: 1 schedule'`, the others on the missing `style=` keyword. - -- [ ] **Step 4: Replace the count-label styles** - -In `src/ui.py` `_configure_styles`, replace: - -```python - self.style.configure( - "NightCount.TLabel", - font=FONTS.bold, - foreground=COLORS.success, - background=COLORS.surface, - ) - self.style.configure( - "DayCount.TLabel", - font=FONTS.bold, - foreground=COLORS.success, - background=COLORS.surface, - ) -``` - -with: - -```python - # Semantic readiness states. Colour reinforces the label text; it is - # never the only signal, per the DESIGN.md validation-state rule. - for count_style, count_color in ( - ("CountReady.TLabel", COLORS.success), - ("CountMuted.TLabel", COLORS.text_dim), - ("CountError.TLabel", COLORS.error), - ): - self.style.configure( - count_style, - font=FONTS.bold, - foreground=count_color, - background=COLORS.surface, - ) -``` - -- [ ] **Step 5: Point the count label at the ready style** - -In `src/ui.py` `_create_shift_panel`, replace: - -```python - count_label = ttk.Label( - card, - text="Selected · 1 document", - style=f"{label}Count.TLabel", - ) -``` - -with: - -```python - count_label = ttk.Label( - card, - text="Selected · 1 document", - style="CountReady.TLabel", - ) -``` - -- [ ] **Step 6: Import PrintJob for the manifest type hint** - -In `src/ui.py`, change: - -```python -from .print_manifest import ( - DateMode, - ShiftSelection, - ShiftType, - build_print_manifest, -) -``` - -to: - -```python -from .print_manifest import ( - DateMode, - PrintJob, - ShiftSelection, - ShiftType, - build_print_manifest, -) -``` - -- [ ] **Step 7: Rewrite the preview** - -In `src/ui.py`, replace the entire `refresh_manifest_preview` method with these six methods: - -```python - @staticmethod - def _document_noun(count: int) -> str: - """Return the correctly pluralized document noun for *count*.""" - return "document" if count == 1 else "documents" - - @staticmethod - def _print_button_text(count: int) -> str: - """Return the count-bearing label for the primary action.""" - if count == 0: - return "Print schedules" - if count == 1: - return "Print 1 schedule" - return f"Print {count} schedules" - - def _set_count(self, shift_type: ShiftType, text: str, style: str) -> None: - """Set one panel's count line text and readiness style together.""" - self._shift_panels[shift_type].count_label.config(text=text, style=style) - - def _refresh_shift_counts( - self, - selections: tuple[ShiftSelection, ShiftSelection], - errors: dict[ShiftType, Optional[str]], - ) -> None: - """Update each panel independently so one bad shift cannot accuse the other.""" - for selection in selections: - shift_type = selection.shift_type - if not selection.enabled: - self._set_count(shift_type, "Not included", "CountMuted.TLabel") - elif errors[shift_type]: - self._set_count( - shift_type, - f"Check {shift_type.title()} date selection", - "CountError.TLabel", - ) - else: - count = len(build_print_manifest((selection,))) - self._set_count( - shift_type, - f"Selected · {count} {self._document_noun(count)}", - "CountReady.TLabel", - ) - - def _describe_manifest( - self, - selections: tuple[ShiftSelection, ShiftSelection], - manifest: tuple[PrintJob, ...], - ) -> tuple[str, list[str]]: - """Return the manifest title and one numbered line per included shift.""" - if not manifest: - return "This run: No schedules selected", [] - - total = len(manifest) - title = f"This run: {total} schedule{'' if total == 1 else 's'}" - lines: list[str] = [] - row_number = 1 - for selection in selections: - if not selection.enabled: - continue - count = sum( - 1 for job in manifest if job.shift_type == selection.shift_type - ) - lines.append( - f"{row_number}. {self._format_selection_summary(selection, count)}" - ) - row_number += 1 - return title, lines - - def refresh_manifest_preview(self) -> None: - """Refresh the preflight-neutral manifest copy and count-aware action.""" - if len(self._shift_panels) != 2: - return - - selections = self.get_shift_selections() - errors: dict[ShiftType, Optional[str]] = { - selection.shift_type: selection.validate() for selection in selections - } - self._refresh_shift_counts(selections, errors) - - invalid = [s for s in selections if errors[s.shift_type]] - if invalid: - manifest: tuple[PrintJob, ...] = () - names = " and ".join(s.shift_type.title() for s in invalid) - title = f"This run: Check {names} date selection" - lines = [errors[s.shift_type] or "" for s in invalid] - else: - manifest = build_print_manifest(selections) - title, lines = self._describe_manifest(selections, manifest) - - printer = self.get_printer_name() - printer_label = ( - printer - if printer and printer != DEFAULT_PRINTER_LABEL - else "Choose a printer" - ) - lines.append(f"Printer: {printer_label}") - - if self.manifest_title_label is not None: - self.manifest_title_label.config(text=title) - if self.manifest_label is not None: - self.manifest_label.config(text="\n".join(lines)) - if self.print_btn is not None: - self.print_btn.config(text=self._print_button_text(len(manifest))) -``` - -- [ ] **Step 8: Run the tests to verify they pass** - -Run: `.venv312/bin/pytest tests/test_ui.py -v` -Expected: PASS. - -- [ ] **Step 9: Run the full suite and gates** - -Run: -```bash -.venv312/bin/pytest -q && .venv312/bin/black src tests && .venv312/bin/mypy src && .venv312/bin/pylint src --fail-under=8.0 -``` -Expected: 234 passed; all gates clean. - -- [ ] **Step 10: Commit** - -```bash -git add src/ui.py tests/test_ui.py -git commit -m "fix: attribute date errors per shift and pluralize run scope" -``` - ---- - -### Task 8: Use the shared validator in the controller - -`_validate_inputs` performs its own inline range checking, which is now a second implementation of the same rule. Route it through `ShiftSelection.validate()` so the preview and the blocking validation can never diverge. - -**Files:** -- Modify: `src/main.py:16` (import), `src/main.py:182-190` (inline validation loop) -- Test: `tests/test_main.py` - -**Interfaces:** -- Consumes: `ShiftSelection.validate() -> Optional[str]` from Task 4. -- Produces: no signature change to `_validate_inputs`. - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_main.py`, in the class that holds the other `_validate_inputs` tests: - -```python - def test_validate_inputs_reports_shift_labelled_range_error(self, app): - """A reversed Night range must be rejected with the shift named.""" - night = ShiftSelection( - shift_type="night", - enabled=True, - mode="range", - start_date=date(2026, 8, 10), - end_date=date(2026, 8, 1), - folder="/templates/night", - ) - day = ShiftSelection( - shift_type="day", - enabled=False, - mode="single", - start_date=date(2026, 8, 1), - end_date=date(2026, 8, 1), - folder="/templates/day", - ) - app.ui.get_shift_selections.return_value = (night, day) - - request, error = app._validate_inputs() - - assert request is None - assert error == "Night schedule: End date cannot be before start date" -``` - -Ensure `tests/test_main.py` imports what the test needs: - -```python -from src.print_manifest import ShiftSelection -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `.venv312/bin/pytest tests/test_main.py -k shift_labelled -v` -Expected: FAIL — the current message is `Invalid Night date selection: End date cannot be before start date`. - -- [ ] **Step 3: Import the validator path** - -In `src/main.py`, remove `validate_date_range` from the scheduler import if it becomes unused: - -```python -from .scheduler import get_english_day_name -``` - -- [ ] **Step 4: Replace the inline loop** - -In `src/main.py` `_validate_inputs`, replace: - -```python - for selection in enabled_selections: - label = selection.shift_type.title() - try: - start_date, end_date = selection.active_range() - except ValueError as e: - return None, str(e) - is_valid, error_msg = validate_date_range(start_date, end_date) - if not is_valid: - return None, f"Invalid {label} date selection: {error_msg}" -``` - -with: - -```python - for selection in enabled_selections: - selection_error = selection.validate() - if selection_error: - return None, selection_error -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `.venv312/bin/pytest tests/test_main.py -v` -Expected: PASS. If an existing test asserted the old `"Invalid Night date selection: ..."` wording, update it to the new `"Night schedule: ..."` wording. - -- [ ] **Step 6: Run the full suite and gates** - -Run: -```bash -.venv312/bin/pytest -q && .venv312/bin/black src tests && .venv312/bin/mypy src && .venv312/bin/pylint src --fail-under=8.0 -``` -Expected: 235 passed; all gates clean. Pylint's `too-many-branches` and `too-many-locals` warnings on `_validate_inputs` should reduce. - -- [ ] **Step 7: Commit** - -```bash -git add src/main.py tests/test_main.py -git commit -m "refactor: validate shift selections through one shared rule" -``` - ---- - -### Task 9: CI gates, release versioning, docs, and copy - -The build job can publish a release while tests fail, the documented pylint gate is not enforced, the release version is hand-typed and drifts from the in-app version, the README's only visual shows a differently-named app with a different layout, and the README advertises a removed feature. - -**Files:** -- Modify: `.github/workflows/build.yml`, `README.md:15-30`, `docs/windows-smoke-test.md`, `src/main.py:575` (dialog copy), `src/main.py:94` (comment) -- Delete: `docs/screenshots/main.png` - -**Interfaces:** -- Consumes: nothing. -- Produces: no code interfaces. `src/__init__.py.__version__` becomes the single source of release version truth. - -- [ ] **Step 1: Make the build depend on quality** - -In `.github/workflows/build.yml`, change: - -```yaml - build: - if: github.event_name == 'workflow_dispatch' - runs-on: windows-latest -``` - -to: - -```yaml - build: - needs: quality - if: github.event_name == 'workflow_dispatch' - runs-on: windows-latest -``` - -- [ ] **Step 2: Enforce the documented pylint gate** - -In the `quality` job, add this step immediately after the `Type check` step: - -```yaml - - name: Lint - run: pylint src --fail-under=8.0 -``` - -- [ ] **Step 3: Single-source the release version** - -Remove the `version` input from `workflow_dispatch`, leaving: - -```yaml -on: - workflow_dispatch: - inputs: - create_release: - description: "Create a GitHub Release" - type: boolean - default: false -``` - -In the `build` job, add this step immediately after `Install dependencies`: - -```yaml - - name: Read version - id: version - shell: bash - run: | - v=$(python -c "import re, pathlib; print(re.search(r'__version__ = \"([^\"]+)\"', pathlib.Path('src/__init__.py').read_text()).group(1))") - echo "value=$v" >> "$GITHUB_OUTPUT" -``` - -Then replace every `${{ inputs.version }}` with `${{ steps.version.outputs.value }}`, in the artifact name and both release fields: - -```yaml - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: ShiftPress-v${{ steps.version.outputs.value }} - path: dist/*.exe - retention-days: 90 - - - name: Create Release - if: inputs.create_release - uses: softprops/action-gh-release@v2 - with: - tag_name: v${{ steps.version.outputs.value }} - name: v${{ steps.version.outputs.value }} - generate_release_notes: true - files: dist/*.exe -``` - -- [ ] **Step 4: Validate the workflow YAML** - -Run: -```bash -.venv312/bin/python -c "import yaml, pathlib; d = yaml.safe_load(pathlib.Path('.github/workflows/build.yml').read_text()); print(d['jobs']['build']['needs']); print([s.get('name') for s in d['jobs']['quality']['steps']])" -``` -Expected: prints `quality`, and a step list containing `Lint`. If `yaml` is missing, run `.venv312/bin/pip install -q pyyaml` first. - -- [ ] **Step 5: Remove the stale screenshot** - -Run: -```bash -git rm docs/screenshots/main.png -``` - -In `README.md`, delete these three lines: - -```markdown -## Preview - -![Main window](docs/screenshots/main.png) - -``` - -- [ ] **Step 6: Remove the advertised feature that does not exist** - -In `README.md`, delete this line from Core Features: - -```markdown -- Date replacement automation with optional header/footer-only mode -``` - -and replace it with: - -```markdown -- Date replacement across body, header, and footer story ranges -``` - -- [ ] **Step 7: Record the screenshot refresh in the smoke test** - -Append to `docs/windows-smoke-test.md`: - -```markdown -## Refresh the README screenshot - -The README has no screenshot. Capture one here, where a real Windows instance -with Microsoft Word exists: - -1. Launch ShiftPress with both template folders configured and a printer selected. -2. Capture the main window to `docs/screenshots/main.png`. -3. Restore the README `## Preview` section above `## Core Features`: - - ```markdown - ## Preview - - ![Main window](docs/screenshots/main.png) - ``` -``` - -- [ ] **Step 8: Fix the failure-dialog copy** - -In `src/main.py` `_show_failure_summary`, replace: - -```python - message += "\n\nTip: Click 'Open Logs' in the app footer." -``` - -with: - -```python - message += "\n\nTip: Click 'Open logs' in the app footer." -``` - -- [ ] **Step 9: Correct the keyboard-shortcut comment** - -In `src/main.py` `__init__`, replace: - -```python - # Set up button command and keyboard shortcuts (Enter = start, Escape = cancel) -``` - -with: - -```python - # Wire the print button. Enter starts a run only while the button has - # focus; Escape cancels from anywhere in the window. -``` - -- [ ] **Step 10: Run the full suite and gates** - -Run: -```bash -.venv312/bin/pytest -q && .venv312/bin/black src tests && .venv312/bin/mypy src && .venv312/bin/pylint src --fail-under=8.0 -``` -Expected: 235 passed; all gates clean. If a test asserted the `'Open Logs'` wording, update it. - -- [ ] **Step 11: Verify no stale references remain** - -Run: -```bash -grep -rn "header/footer\|main.png\|inputs.version" README.md .github/ docs/ src/ || echo "clean" -``` -Expected: only the smoke-test doc's instructions for restoring the screenshot. - -- [ ] **Step 12: Commit** - -```bash -git add -A -git commit -m "ci: gate releases on quality and single-source the version" -``` - ---- - -## Final verification - -After Task 9, run the complete gate set one more time and confirm the numbers: - -```bash -.venv312/bin/pytest -q && .venv312/bin/black --check src tests && .venv312/bin/mypy src && .venv312/bin/pylint src --fail-under=8.0 -``` - -Expected: 235 passed, black clean, mypy clean, pylint at or above 8.0. - -Then re-run the real-Tk measurement from Task 5 Step 9 and confirm `equal=True` and `minsize` height equal to `reqheight`. - -Report the final test count, coverage percentage, and pylint score. diff --git a/docs/superpowers/plans/2026-07-31-shiftprint-finding-gates.md b/docs/superpowers/plans/2026-07-31-shiftprint-finding-gates.md deleted file mode 100644 index 89e26a1..0000000 --- a/docs/superpowers/plans/2026-07-31-shiftprint-finding-gates.md +++ /dev/null @@ -1,44 +0,0 @@ -# ShiftPrint Finding-Only Scanner Gates Implementation Plan - -**Goal:** Finish the Relay-derived Sonar and Snyk finding-only gates for ShiftPrint without introducing a Node package manifest, while preserving the verified Windows build and release safeguards. - -**Architecture:** Keep the existing bounded Node gate runners as dependency-free orchestration around globally installed scanner executables. GitHub Actions produces Python LCOV coverage, invokes the Sonar and Snyk runners for internal pull requests and `test` pushes, and keeps transient scanner outages non-blocking while findings and configuration failures block. ShiftPrint begins with an empty reviewed Sonar exception manifest. - -**Tech Stack:** Python 3.12, pytest/coverage, Node.js built-in test runner, SonarCloud, Snyk CLI, GitHub Actions, PyInstaller, pywin32, tkcalendar. - ---- - -### Task 1: Adapt the scanner contracts to ShiftPrint - -**Files:** -- Modify: `scripts/security-workflow-contract.test.mjs` -- Modify: `scripts/run-sonar-ci.test.mjs` -- Modify: `scripts/sonar-open-findings.test.mjs` -- Modify: `scripts/sonar-reviewed-issues.test.mjs` - -1. Replace Relay package-script and 49-exception expectations with direct executable and zero-exception expectations. -2. Preserve regression coverage for scope restrictions, failure classification, deadlines, token redaction, and workflow job names. -3. Keep workflow contract inspection dependency-free so no `package.json` or npm install is required to run tests. - -### Task 2: Complete direct scanner execution and workflows - -**Files:** -- Modify: `scripts/run-sonar-ci.mjs` -- Create: `.github/workflows/security.yml` -- Modify: `.github/workflows/build.yml` - -1. Invoke the globally installed Sonar scanner executable directly. -2. Add bounded Sonar and Snyk jobs for internal pull requests targeting `test` and pushes to `test`. -3. Generate LCOV for Sonar, install pinned scanner CLIs globally, and call the dependency-free Node runners. -4. Give the existing quality job the stable `Build quality gate` name, run scanner regression tests there, and preserve the Windows test plus `build` dependencies that prevent packaging or release after failed tests. - -### Task 3: Verify locally and publish the implementation branch - -**Files:** -- Verify all changed workflow and scanner files. - -1. Run all 80 scanner regression tests. -2. Run the full Python quality sequence and the complete 248-test suite with coverage. -3. Validate workflow syntax and inspect the final diff for secrets, stale ShiftPress app naming, or Relay-specific assumptions. -4. Commit and push `ci/finding-only-scanner-gates`, open a pull request into `test`, and inspect GitHub Actions. -5. Configure required checks only after both scanner credentials exist and the checks have completed successfully; otherwise report the exact external blocker without weakening the fail-closed configuration behavior. diff --git a/docs/superpowers/plans/2026-07-31-shiftprint-rename.md b/docs/superpowers/plans/2026-07-31-shiftprint-rename.md deleted file mode 100644 index 40c917b..0000000 --- a/docs/superpowers/plans/2026-07-31-shiftprint-rename.md +++ /dev/null @@ -1,826 +0,0 @@ -# ShiftPrint Rename and Icon Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Rename the application from ShiftPress to ShiftPrint, migrate existing operators' saved config to the new data directory, and ship an icon that matches the current design system. - -**Architecture:** Five tasks in dependency order. The icon is independent and goes first. Then the identity constants change together with a legacy-directory accessor, then the config migration that consumes it, then the bulk textual rename, then prose docs. Historical design documents are deliberately left under the old name. - -**Tech Stack:** Python 3.12, Pillow (new dev-only dependency, for icon generation and icon tests), Tkinter/ttk, pytest, black, mypy, pylint, PyInstaller, GitHub Actions. - -## Global Constraints - -- Spec: `docs/superpowers/specs/2026-07-31-shiftprint-rename-design.md`. Read it before starting. -- Work directly on the current branch (`test`). No worktree. -- New name is exactly `ShiftPrint` (capital S, capital P). Lowercase form is `shiftprint`. -- All existing tests must stay green. Run the full suite before every commit. -- `black --check src tests` clean. Run `black src tests` before committing. -- `mypy src` reports no issues. `pylint src --fail-under=8.0` passes. -- Python 3.12 required. The system `python3` on this host is 3.9 and will not work. Use `.venv/bin/*`, which already exists. -- Pillow goes in `requirements-dev.txt` **only**. Never `requirements.txt` — the app loads `icon.ico` through Tkinter and needs no imaging library at runtime. -- **Never rewrite historical documents.** `docs/plans/**` and `docs/superpowers/**` (except files this plan creates) keep the old name. They are dated records of decisions made under it. -- **`src/app_paths.py` must retain the literal `"ShiftPress"`** as the legacy directory name. A blanket find-and-replace across it silently breaks migration. - ---- - -### Task 1: Icon generation and assets - -The current icon is amber-on-zinc from a palette that predates the design system, and its detail dies at 16px. Replace it with the approved composition: two offset sheets, Night behind, Day in front. - -**Files:** -- Create: `tools/make_icon.py` -- Create: `tests/test_icon.py` -- Modify: `requirements-dev.txt` -- Replace: `icon.png`, `icon.ico` - -**Interfaces:** -- Consumes: nothing from earlier tasks. -- Produces: `tools/make_icon.py` exposing `render(size: int = 1024) -> PIL.Image.Image` and `main() -> None`. Nothing in `src/` imports it. - -- [ ] **Step 1: Add Pillow as a dev dependency** - -In `requirements-dev.txt`, under the `# Build tools` section, add: - -``` -# Icon generation and icon asset tests (not needed at runtime) -pillow>=12.3.0 -``` - -Then install it: - -```bash -.venv/bin/pip install -r requirements-dev.txt -``` - -- [ ] **Step 2: Write the failing tests** - -Create `tests/test_icon.py`: - -```python -"""The shipped icon assets must stay in spec and in palette.""" - -from pathlib import Path - -from PIL import Image - -ROOT = Path(__file__).resolve().parent.parent -REQUIRED_ICO_SIZES = { - (16, 16), - (24, 24), - (32, 32), - (48, 48), - (64, 64), - (128, 128), - (256, 256), -} - - -def test_icon_png_is_full_resolution_rgba(): - """PyInstaller and the Tk fallback both read the PNG master.""" - with Image.open(ROOT / "icon.png") as im: - assert im.size == (1024, 1024) - assert im.mode == "RGBA" - - -def test_icon_ico_carries_every_windows_size(): - """Windows picks a size per context; a missing one gets a blurry upscale.""" - with Image.open(ROOT / "icon.ico") as ico: - assert set(ico.info["sizes"]) == REQUIRED_ICO_SIZES - - -def test_icon_uses_the_committed_shift_tokens(): - """The icon shares the app's palette rather than inventing its own.""" - with Image.open(ROOT / "icon.png") as im: - colors = {c for _, c in im.convert("RGB").getcolors(maxcolors=1_000_000)} - - assert (0x38, 0xBD, 0xF8) in colors # night_accent - assert (0xF2, 0xB3, 0x40) in colors # day_accent - assert (0x16, 0x17, 0x1A) in colors # window charcoal ground -``` - -- [ ] **Step 3: Run the tests to verify they fail** - -Run: `.venv/bin/pytest tests/test_icon.py -v --no-cov` -Expected: `test_icon_uses_the_committed_shift_tokens` FAILS — the current icon is amber `#F59E0B` on zinc-900 `#18181B`, so neither the Night token nor the charcoal ground is present. - -- [ ] **Step 4: Write the generator** - -Create `tools/make_icon.py`: - -```python -"""Regenerate the ShiftPrint application icon. - -Geometry is expressed as a fraction of the canvas, so the mark is resolution -independent. The values come from reading the mark at true 16px: a 0.020 gap -collapses the Night sheet to a sliver and 0.050 reduces it to a bare corner. - -Requires Pillow, which is a development dependency only. The running -application loads icon.ico through Tkinter and needs no imaging library. - -Usage: - .venv/bin/python tools/make_icon.py -""" - -from pathlib import Path - -from PIL import Image, ImageDraw - -CANVAS = 1024 -ICO_SIZES = (16, 24, 32, 48, 64, 128, 256) - -GROUND = "#16171A" # window charcoal -NIGHT = "#38BDF8" # night_accent -DAY = "#F2B340" # day_accent - -TILE_RADIUS = 0.12 # "gently softened corners" per DESIGN.md Shapes -SHEET_RADIUS = 0.035 -SHEET_W = 0.42 -SHEET_H = 0.50 -OFFSET = 0.19 -GAP = 0.035 - - -def render(size: int = CANVAS) -> Image.Image: - """Draw the icon at *size* square. - - Args: - size: Edge length in pixels. - - Returns: - An RGBA image of the mark. - """ - s = size - im = Image.new("RGBA", (s, s), (0, 0, 0, 0)) - d = ImageDraw.Draw(im) - d.rounded_rectangle([0, 0, s, s], radius=int(s * TILE_RADIUS), fill=GROUND) - - r = int(s * SHEET_RADIUS) - nx0 = s * (0.5 - SHEET_W / 2 - OFFSET / 2) - ny0 = s * (0.5 - SHEET_H / 2 - OFFSET / 2) - nx1, ny1 = nx0 + s * SHEET_W, ny0 + s * SHEET_H - dx0, dy0 = nx0 + s * OFFSET, ny0 + s * OFFSET - dx1, dy1 = dx0 + s * SHEET_W, dy0 + s * SHEET_H - - # Night sheet sits behind, up and left. - d.rounded_rectangle([nx0, ny0, nx1, ny1], radius=r, fill=NIGHT) - # Ground-coloured gap keeps the two sheets legible at 16px. - g = s * GAP - d.rounded_rectangle([dx0 - g, dy0 - g, dx1 + g, dy1 + g], radius=r, fill=GROUND) - # Day sheet sits in front, down and right. - d.rounded_rectangle([dx0, dy0, dx1, dy1], radius=r, fill=DAY) - return im - - -def main() -> None: - """Write icon.png and icon.ico to the repository root.""" - root = Path(__file__).resolve().parent.parent - master = render() - master.save(root / "icon.png") - master.save(root / "icon.ico", sizes=[(n, n) for n in ICO_SIZES]) - print(f"wrote {root / 'icon.png'} and {root / 'icon.ico'}") - - -if __name__ == "__main__": - main() -``` - -- [ ] **Step 5: Generate the assets** - -Run: `.venv/bin/python tools/make_icon.py` -Expected: prints the two written paths. - -- [ ] **Step 6: Run the tests to verify they pass** - -Run: `.venv/bin/pytest tests/test_icon.py -v --no-cov` -Expected: 3 passed. - -- [ ] **Step 7: Confirm the mark survives 16px** - -Run: - -```bash -.venv/bin/python -c " -from PIL import Image -im = Image.open('icon.png').resize((16,16), Image.LANCZOS).convert('RGB') -cols = {c for _, c in im.getcolors(maxcolors=100000)} -def near(t, cs, tol=60): - return any(sum(abs(a-b) for a,b in zip(t,c)) < tol for c in cs) -print('night visible at 16px:', near((0x38,0xBD,0xF8), cols)) -print('day visible at 16px:', near((0xF2,0xB3,0x40), cols)) -" -``` - -Expected: both `True`. If either is `False`, the sheets have merged and the `GAP` value needs revisiting before continuing. - -- [ ] **Step 8: Run the full suite and gates** - -Run: -```bash -.venv/bin/pytest -q && .venv/bin/black src tests && .venv/bin/mypy src && .venv/bin/pylint src --fail-under=8.0 -``` -Expected: 242 passed (239 existing + 3 new); all gates clean. `tools/` is outside `src` and `tests`, so it is not linted or type-checked by the gates. - -- [ ] **Step 9: Commit** - -```bash -git add tools/make_icon.py tests/test_icon.py requirements-dev.txt icon.png icon.ico -git commit -m "feat: ship an icon that matches the design system" -``` - ---- - -### Task 2: Identity constants and the legacy data directory - -`APP_DIRNAME` drives `%APPDATA%\ShiftPress`. Renaming it points the app at an empty directory, so the old location must remain reachable for Task 3's migration. - -**Files:** -- Modify: `src/app_paths.py` (whole file) -- Modify: `src/constants.py:88` (`LOG_FILENAME`) -- Test: `tests/test_app_paths.py` - -**Interfaces:** -- Consumes: nothing from earlier tasks. -- Produces: `app_paths.APP_DIRNAME = "ShiftPrint"`, `app_paths.LEGACY_APP_DIRNAME = "ShiftPress"`, `get_data_dir() -> Path`, and `get_legacy_data_dir() -> Path`. Task 3 consumes `get_legacy_data_dir`. - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_app_paths.py`, inside `class TestGetDataDir`: - -```python - def test_legacy_dir_differs_from_current_on_windows(self): - """Migration needs both locations to be reachable and distinct.""" - with patch("os.name", "nt"), patch.dict( - os.environ, {"APPDATA": "C:\\Users\\Test\\AppData\\Roaming"}, clear=True - ): - assert get_data_dir().name == "ShiftPrint" - assert get_legacy_data_dir().name == "ShiftPress" - - def test_legacy_dir_differs_from_current_elsewhere(self): - """The non-Windows dev path also has to be distinguishable.""" - with patch("os.name", "posix"), patch( - "pathlib.Path.home", return_value=Path("/home/testuser") - ): - assert str(get_data_dir()) == "/home/testuser/.shiftprint" - assert str(get_legacy_data_dir()) == "/home/testuser/.shiftpress" -``` - -Update the import at the top of `tests/test_app_paths.py` to include the new accessor: - -```python -from src.app_paths import get_data_dir, get_legacy_data_dir -``` - -Confirm `os`, `Path`, and `patch` are already imported in that file; add any that are missing. - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `.venv/bin/pytest tests/test_app_paths.py -k legacy_dir -v --no-cov` -Expected: FAIL with `ImportError: cannot import name 'get_legacy_data_dir'`. - -- [ ] **Step 3: Rewrite `src/app_paths.py`** - -Replace the whole file with: - -```python -"""App-specific filesystem paths. - -The app writes user-specific state (config/logs) to an OS-appropriate per-user -directory by default, rather than the current working directory. -""" - -from __future__ import annotations - -import os -from pathlib import Path - - -APP_DIRNAME = "ShiftPrint" -APP_DOTNAME = ".shiftprint" - -# The app was named ShiftPress before 2026-07-31. Existing installs still have -# their config here, so the old location stays reachable for migration. -LEGACY_APP_DIRNAME = "ShiftPress" -LEGACY_APP_DOTNAME = ".shiftpress" - - -def _data_dir_for(app_dirname: str, dotname: str) -> Path: - """Return the per-user directory for one app identity. - - Args: - app_dirname: Windows directory name under %APPDATA%. - dotname: Dot-directory name used on other platforms. - - Returns: - Path to the per-user data directory. Not created. - """ - if os.name == "nt": - base = os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA") - if base: - return Path(base) / app_dirname - return Path.home() / app_dirname - - # Non-Windows environments are primarily for development/tests. - return Path.home() / dotname - - -def get_data_dir() -> Path: - """Return the per-user data directory for the app. - - The directory is *not* created by this function; callers are responsible - for calling ``mkdir()`` if needed. - - Windows: %APPDATA%\\ShiftPrint (fallback to %LOCALAPPDATA%) - Other OSes (dev/test): ~/.shiftprint - - Returns: - Path to the per-user data directory. - """ - return _data_dir_for(APP_DIRNAME, APP_DOTNAME) - - -def get_legacy_data_dir() -> Path: - """Return the per-user data directory used before the ShiftPrint rename. - - Returns: - Path to the pre-rename data directory. Not created. - """ - return _data_dir_for(LEGACY_APP_DIRNAME, LEGACY_APP_DOTNAME) -``` - -- [ ] **Step 4: Rename the log file** - -In `src/constants.py`, change: - -```python -LOG_FILENAME: Final = "shiftpress.log" -``` - -to: - -```python -LOG_FILENAME: Final = "shiftprint.log" -``` - -- [ ] **Step 5: Update the existing non-Windows path test** - -In `tests/test_app_paths.py`, the existing `test_non_windows` asserts the old dot-directory. Change its docstring and assertion: - -```python - """Should use ~/.shiftprint on non-Windows.""" -``` - -```python - assert str(result) == "/home/testuser/.shiftprint" -``` - -Also update `tests/test_logger.py:52`, which asserts the log filename: - -```python - assert log_files[0].name == "shiftprint.log" -``` - -- [ ] **Step 6: Run the full suite and gates** - -Run: -```bash -.venv/bin/pytest -q && .venv/bin/black src tests && .venv/bin/mypy src && .venv/bin/pylint src --fail-under=8.0 -``` -Expected: 244 passed; all gates clean. - -- [ ] **Step 7: Commit** - -```bash -git add src/app_paths.py src/constants.py tests/test_app_paths.py tests/test_logger.py -git commit -m "feat: point the data directory at ShiftPrint and keep the old one reachable" -``` - ---- - -### Task 3: Migrate config from the pre-rename data directory - -Without this, every existing operator silently loses their template folders and printer selection on first launch after upgrading. - -**Files:** -- Modify: `src/config.py` (imports, `__init__`, `load`) -- Test: `tests/test_config.py` - -**Interfaces:** -- Consumes: `app_paths.get_legacy_data_dir() -> Path` from Task 2. -- Produces: `ConfigManager._legacy_data_config_path: Path` and `ConfigManager._migrate_from(legacy_path: Path) -> Optional[AppConfig]`. The existing `_legacy_config_path` attribute keeps its name — `tests/test_config.py::test_legacy_migration` sets it directly. - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_config.py`, inside `class TestConfigManager`: - -```python - def test_migrates_config_from_pre_rename_data_dir(self, tmp_path): - """An operator upgrading from ShiftPress keeps their saved setup.""" - legacy_dir = tmp_path / "legacy" - legacy_dir.mkdir() - (legacy_dir / "config.json").write_text( - json.dumps({"day_folder": "/old/day", "printer_name": "OldPrinter"}) - ) - - manager = ConfigManager() - manager.config_path = tmp_path / "current" / "config.json" - manager._legacy_data_config_path = legacy_dir / "config.json" - manager._legacy_config_path = tmp_path / "nonexistent-cwd" / "config.json" - manager._allow_legacy_migration = True - - config = manager.load() - - assert config.day_folder == "/old/day" - assert config.printer_name == "OldPrinter" - assert manager.config_path.exists() - - def test_pre_rename_config_is_renamed_after_migration(self, tmp_path): - """The old file must not be re-migrated over a later edit.""" - legacy_dir = tmp_path / "legacy" - legacy_dir.mkdir() - legacy_file = legacy_dir / "config.json" - legacy_file.write_text(json.dumps({"day_folder": "/old/day"})) - - manager = ConfigManager() - manager.config_path = tmp_path / "current" / "config.json" - manager._legacy_data_config_path = legacy_file - manager._legacy_config_path = tmp_path / "nonexistent-cwd" / "config.json" - manager._allow_legacy_migration = True - - manager.load() - - assert not legacy_file.exists() - assert (legacy_dir / "config.json.migrated").exists() - - def test_existing_config_wins_over_pre_rename_one(self, tmp_path): - """Migration must never overwrite a config the operator already has.""" - legacy_dir = tmp_path / "legacy" - legacy_dir.mkdir() - (legacy_dir / "config.json").write_text(json.dumps({"day_folder": "/old/day"})) - new_dir = tmp_path / "current" - new_dir.mkdir() - (new_dir / "config.json").write_text(json.dumps({"day_folder": "/current/day"})) - - manager = ConfigManager() - manager.config_path = new_dir / "config.json" - manager._legacy_data_config_path = legacy_dir / "config.json" - manager._allow_legacy_migration = True - - assert manager.load().day_folder == "/current/day" - - def test_unreadable_pre_rename_config_falls_back_to_defaults(self, tmp_path): - """A corrupt old config must not stop the app from starting.""" - legacy_dir = tmp_path / "legacy" - legacy_dir.mkdir() - (legacy_dir / "config.json").write_text("{ not valid json") - - manager = ConfigManager() - manager.config_path = tmp_path / "current" / "config.json" - manager._legacy_data_config_path = legacy_dir / "config.json" - manager._legacy_config_path = tmp_path / "nonexistent-cwd" / "config.json" - manager._allow_legacy_migration = True - - config = manager.load() - - assert config.day_folder == "" - assert config.printer_name == "" -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `.venv/bin/pytest tests/test_config.py -k "pre_rename" -v --no-cov` -Expected: FAIL with `AttributeError: 'ConfigManager' object has no attribute '_legacy_data_config_path'`. - -- [ ] **Step 3: Import the legacy directory accessor** - -In `src/config.py`, change: - -```python -from .app_paths import get_data_dir -``` - -to: - -```python -from .app_paths import get_data_dir, get_legacy_data_dir -``` - -- [ ] **Step 4: Register the second legacy location** - -In `ConfigManager.__init__`, immediately after the existing `self._legacy_config_path` line, add: - -```python - self._legacy_data_config_path = get_legacy_data_dir() / CONFIG_FILENAME -``` - -- [ ] **Step 5: Extract the migration into one reusable method** - -Add these two methods to `ConfigManager`, directly above `load`: - -```python - def _legacy_paths(self) -> tuple[Path, ...]: - """Older config locations to check, most recent first.""" - return (self._legacy_data_config_path, self._legacy_config_path) - - def _migrate_from(self, legacy_path: Path) -> Optional[AppConfig]: - """Adopt a config from an older location. - - Args: - legacy_path: Candidate config file from a previous app version. - - Returns: - The migrated config, or ``None`` when *legacy_path* is absent or - unreadable. A failed migration must never stop the app starting. - """ - if not legacy_path.exists(): - return None - - try: - with open(legacy_path, "r", encoding="utf-8") as f: - config = AppConfig.from_dict(json.load(f)) - except Exception as e: - logger.warning(f"Could not load legacy config at {legacy_path}: {e}") - return None - - logger.info( - f"Configuration loaded from legacy path {legacy_path}; " - f"migrating to {self.config_path}" - ) - try: - self.save(config) - except Exception as e: - logger.warning(f"Could not migrate legacy config to {self.config_path}: {e}") - - # Rename so the next launch does not migrate over a newer edit. - try: - migrated = legacy_path.with_suffix(".json.migrated") - legacy_path.rename(migrated) - logger.info(f"Legacy config renamed to {migrated}") - except Exception as e: - logger.debug(f"Could not rename legacy config: {e}") - - return config -``` - -- [ ] **Step 6: Route `load` through it** - -In `ConfigManager.load`, replace the entire `if not self.config_path.exists():` block — from that line down to and including its `return self._config` — with: - -```python - if not self.config_path.exists(): - # Backward-compatibility: earlier versions stored config under the - # ShiftPress data directory, and older ones in the working directory. - if self._allow_legacy_migration: - for legacy_path in self._legacy_paths(): - migrated = self._migrate_from(legacy_path) - if migrated is not None: - self._config = migrated - return self._config - - logger.info(f"Config file not found at {self.config_path}, using defaults") - self._config = AppConfig() - return self._config -``` - -- [ ] **Step 7: Run the tests to verify they pass** - -Run: `.venv/bin/pytest tests/test_config.py -v --no-cov` -Expected: all pass, including the pre-existing `test_legacy_migration`, which still exercises the working-directory path. - -- [ ] **Step 8: Run the full suite and gates** - -Run: -```bash -.venv/bin/pytest -q && .venv/bin/black src tests && .venv/bin/mypy src && .venv/bin/pylint src --fail-under=8.0 -``` -Expected: 248 passed; all gates clean. - -- [ ] **Step 9: Commit** - -```bash -git add src/config.py tests/test_config.py -git commit -m "feat: carry saved config across the ShiftPrint rename" -``` - ---- - -### Task 4: Rename across code, tests, workflow, and scripts - -**Files:** -- Modify: every tracked `.py`, `.bat`, and `.yml` outside `docs/`, **except `src/app_paths.py` and `tests/test_app_paths.py`** -- Modify: `.github/workflows/build.yml` (PyInstaller `--name`) - -**Interfaces:** -- Consumes: nothing. -- Produces: no new symbols. The window title becomes `ShiftPrint`; the built artifact becomes `ShiftPrint.exe`. - -- [ ] **Step 1: Rename everything except the two legacy-bearing files** - -Both exclusions are deliberate and load-bearing. `src/app_paths.py` must keep the literal `"ShiftPress"` for `LEGACY_APP_DIRNAME`, and `tests/test_app_paths.py` asserts that exact literal. A blanket replace would rewrite both sides in lockstep, so the assertion would still pass while the migration quietly pointed at the wrong directory — a green suite hiding a broken upgrade. Task 2 already left both files correct. - -```bash -FILES=$(git ls-files '*.py' '*.bat' '*.yml' \ - | grep -v '^docs/' \ - | grep -v '^src/app_paths.py$' \ - | grep -v '^tests/test_app_paths.py$') -echo "$FILES" -sed -i '' -e 's/ShiftPress/ShiftPrint/g' -e 's/shiftpress/shiftprint/g' -e 's/SHIFTPRESS/SHIFTPRINT/g' $FILES -``` - -- [ ] **Step 2: Verify the legacy name survived where it must** - -Run: - -```bash -grep -n "LEGACY_APP_DIRNAME\|LEGACY_APP_DOTNAME" src/app_paths.py -``` - -Expected: `LEGACY_APP_DIRNAME = "ShiftPress"` and `LEGACY_APP_DOTNAME = ".shiftpress"`. If either now reads `ShiftPrint`, the exclusion failed — restore this file with `git checkout src/app_paths.py`, redo Task 2's Step 3, and rerun Step 1 with the exclusion in place. - -- [ ] **Step 3: Confirm no code-side occurrences remain** - -Run: - -```bash -grep -rn "ShiftPress\|shiftpress" --include="*.py" --include="*.bat" --include="*.yml" . \ - | grep -v '^\./docs/' | grep -v '\.venv' \ - | grep -v '^\./src/app_paths.py' | grep -v '^\./tests/test_app_paths.py' -``` - -Expected: no output. The only legitimate remaining occurrences are the legacy constants in `src/app_paths.py` and the assertions covering them in `tests/test_app_paths.py`; both are filtered above. Any other hit is a miss. - -Then confirm those two files still say what they should: - -```bash -grep -c "ShiftPress" src/app_paths.py tests/test_app_paths.py -``` - -Expected: `src/app_paths.py:1` and `tests/test_app_paths.py:1` — one legacy literal each. A `0` means the exclusion failed. - -- [ ] **Step 4: Run the full suite and gates** - -Run: -```bash -.venv/bin/pytest -q && .venv/bin/black src tests && .venv/bin/mypy src && .venv/bin/pylint src --fail-under=8.0 -``` -Expected: 248 passed; all gates clean. The suite covers the renamed logger tag (`_shiftprint`) and default logger name (`shiftprint`) because `sed` rewrote both the source and its assertions together. - -- [ ] **Step 5: Confirm the app still launches under the new identity** - -Run: - -```bash -.venv/bin/python -c " -import tkinter as tk -from datetime import date -from src.ui import ScheduleAppUI -from src.app_paths import get_data_dir, get_legacy_data_dir -root = tk.Tk() -ui = ScheduleAppUI(root, today=date(2026, 7, 31)) -root.update_idletasks() -print('window title :', root.title()) -print('data dir :', get_data_dir()) -print('legacy dir :', get_legacy_data_dir()) -root.destroy() -" 2>&1 | grep -v 'win32print' -``` - -Expected: title `ShiftPrint`, data dir ending `.shiftprint`, legacy dir ending `.shiftpress`. - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "refactor: rename ShiftPress to ShiftPrint across code and build" -``` - ---- - -### Task 5: Documentation, brand commitment, and the icon record - -**Files:** -- Modify: `README.md`, `PRODUCT.md`, `DESIGN.md`, `docs/windows-smoke-test.md`, `.impeccable/surfaces/src-ui-py.md` - -**Interfaces:** -- Consumes: the icon geometry from Task 1. -- Produces: no code symbols. - -- [ ] **Step 1: Rename in prose docs only** - -`docs/plans/**` and `docs/superpowers/**` are excluded: they are dated records of decisions made under the old name. - -```bash -sed -i '' -e 's/ShiftPress/ShiftPrint/g' -e 's/shiftpress/shiftprint/g' \ - README.md PRODUCT.md DESIGN.md docs/windows-smoke-test.md .impeccable/surfaces/src-ui-py.md -``` - -- [ ] **Step 2: Update the brand commitment** - -`PRODUCT.md`'s commitment now reads "Preserve the ShiftPrint name" after Step 1, which is correct. Directly below that bullet, replace the icon bullet: - -```markdown -- Existing application icons are available at `icon.ico` and `icon.png`. -``` - -with: - -```markdown -- Application icons live at `icon.ico` and `icon.png`, regenerated from - `tools/make_icon.py`; see the Icon section of DESIGN.md. -``` - -- [ ] **Step 3: Record the icon in DESIGN.md** - -This is the durable system change the user approved. In `DESIGN.md`, insert this section immediately before `## Typography`: - -```markdown -## Icon - -**Concept: the pair leads.** Two offset sheets — Night behind, Day in front — -on a charcoal ground. The mark states what the app is for: two shift schedules, -chosen independently. - -Geometry is a fraction of the canvas, so the mark is resolution independent. - -| Element | Value | -| --- | --- | -| Ground tile | `#16171A`, full bleed, corner radius `0.12` | -| Night sheet | `#38BDF8`, origin `(0.195, 0.155)`, size `0.42 × 0.50`, radius `0.035` | -| Day sheet | `#F2B340`, origin `(0.385, 0.345)`, size `0.42 × 0.50`, radius `0.035` | -| Separation gap | `0.035` of canvas, in ground colour, behind the Day sheet | - -Outputs are `icon.png` at 1024×1024 and `icon.ico` at 16/24/32/48/64/128/256, -generated by `tools/make_icon.py`. - -**The 16px Rule.** The separation gap exists so both sheets stay readable in the -Windows taskbar. At `0.020` the Night sheet collapses to a sliver; at `0.050` it -reduces to a bare corner. Any change to the geometry must be re-read at true -16px, not judged at full size. - -The icon takes no colour outside this system and carries no shadow or gradient, -per the Flat Native Rule. -``` - -- [ ] **Step 4: Record the approved comp in the surface brief** - -In `.impeccable/surfaces/src-ui-py.md`, append to the `## Resolved Decisions` section: - -```markdown - -The application icon was resolved as "the pair leads": two offset sheets, Night -behind and Day in front. Approved comp: -`.impeccable/mocks/icon-2-two-sheets-approved.png`. -``` - -- [ ] **Step 5: Verify only historical documents still carry the old name** - -Run: - -```bash -grep -rln "ShiftPress\|shiftpress" --include="*.md" . | grep -v '\.venv' | sort -``` - -Expected: exactly `./docs/plans/2026-03-03-icon-and-rename-design.md`, `./docs/superpowers/plans/2026-07-31-review-defect-remediation.md`, `./docs/superpowers/specs/2026-07-30-independent-shift-printing-design.md`, `./docs/superpowers/specs/2026-07-31-review-defect-remediation-design.md`, and `./docs/superpowers/specs/2026-07-31-shiftprint-rename-design.md` (which quotes the old name while describing the change). Anything else is a miss. - -- [ ] **Step 6: Run the full suite and gates** - -Run: -```bash -.venv/bin/pytest -q && .venv/bin/black --check src tests && .venv/bin/mypy src && .venv/bin/pylint src --fail-under=8.0 -``` -Expected: 248 passed; all gates clean. - -- [ ] **Step 7: Commit** - -```bash -git add -A -git commit -m "docs: rename to ShiftPrint and record the icon in the design system" -``` - ---- - -## Final verification - -```bash -.venv/bin/pytest -q -.venv/bin/black --check src tests && .venv/bin/mypy src && .venv/bin/pylint src --fail-under=8.0 -.venv/bin/python tools/make_icon.py && .venv/bin/pytest tests/test_icon.py -q --no-cov -``` - -Expected: 248 passed, all gates clean, icon regenerates deterministically and its tests still pass. - -Then push to `test` and confirm both CI jobs pass — `windows-test` matters here, because it is the only place `%APPDATA%` resolution runs against real pywin32. - -Report the final test count, coverage percentage, and pylint score. - -## Held back deliberately - -These are **not** part of this plan. They are outward-facing, and the folder rename would move the working directory out from under an in-progress session. Do them last, with the user present, or in a fresh session: - -```bash -gh repo rename ShiftPrint -git remote set-url origin https://github.com/CrimsonSoul/ShiftPrint.git -# then, from outside the directory: -mv /Users/ryan/Apps/ShiftPress /Users/ryan/Apps/ShiftPrint -``` - -GitHub redirects the old URL, so existing clones keep working. diff --git a/docs/superpowers/plans/2026-08-31-production-trust-and-frontend-polish.md b/docs/superpowers/plans/2026-08-31-production-trust-and-frontend-polish.md deleted file mode 100644 index 6b0d600..0000000 --- a/docs/superpowers/plans/2026-08-31-production-trust-and-frontend-polish.md +++ /dev/null @@ -1,286 +0,0 @@ -# Production Trust and Frontend Polish Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Resolve every finding from the 2026-08-31 ShiftPress codebase and frontend review, then re-run the frontend critique with a target score of 40/40. - -**Architecture:** Keep the existing Tkinter/ttk visual system and controller boundaries. Make Word operations fail closed, make the controller own truthful completed-job outcomes, and make the UI derive actionable readiness from locally knowable state while leaving filesystem/Word checks in the existing preflight boundary. Add only direct helpers required by these behaviors; do not introduce new frameworks or abstraction layers. - -**Tech Stack:** Python 3.12, Tkinter/ttk, pywin32 Word COM, tkcalendar, pytest, GitHub Actions, Impeccable. - -**Spec:** `.impeccable/critique/2026-08-31T15-59-52Z__src-ui-py.md` - -## Global Constraints - -- Preserve the approved "Operator's Print Desk" visual direction in `DESIGN.md`. -- Preserve independent Night and Day selections and the common Night-today/Day-tomorrow defaults. -- Do not print when the requested printer cannot be selected or no supported date text was replaced. -- Advance progress only after a document attempt finishes; final status must match success, partial failure, or cancellation. -- Disable Print only for locally knowable blockers; keep file, template, Word, and printer-device checks in preflight. -- Preserve standard Tkinter/ttk widgets, Windows scaling, source-document read-only behavior, and the existing physical-printer smoke-test boundary. -- Keep user settings recoverable if migration cannot write the new destination. -- Permit release publication only from `refs/heads/main`. -- Do not add dependencies, commit, push, publish, or dispatch a release in this task. - ---- - -### Task 1: Fail-closed Word preparation and printing - -**Files:** -- Modify: `src/word_processor.py` -- Test: `tests/test_word_processor.py` -- Modify: `README.md` - -**Interfaces:** -- Produces: `WordProcessor.replace_dates(doc: Any, current_date: date) -> int` -- Produces: `_set_active_printer(printer_name: str) -> None` that propagates assignment failures -- Consumes: existing `print_document(...) -> tuple[bool, Optional[str]]` - -- [ ] **Step 1: Write failing tests for printer selection, zero replacements, and macro hardening** - -```python -def test_print_document_active_printer_failure_blocks_print(word_processor): - success, error = word_processor.print_document( - Path("schedule.docx"), date(2026, 8, 31), "Bad Printer" - ) - assert success is False - assert "Printer not found" in (error or "") - word_processor.word_app.Documents.Open.return_value.PrintOut.assert_not_called() - -def test_print_document_no_date_replacement_blocks_print(word_processor): - with patch.object(word_processor, "replace_dates", return_value=0): - success, error = word_processor.print_document( - Path("schedule.docx"), date(2026, 8, 31), "Office Printer" - ) - assert success is False - assert "date" in (error or "").lower() - word_processor.word_app.Documents.Open.return_value.PrintOut.assert_not_called() -``` - -- [ ] **Step 2: Run focused tests and confirm they fail for the old behavior** - -Run: `.venv/bin/pytest tests/test_word_processor.py -k 'active_printer_failure or no_date_replacement or macros_cannot_be_disabled' -q` - -- [ ] **Step 3: Implement the minimal fail-closed behavior** - -```python -replacement_count = self.replace_dates(doc, current_date) -if replacement_count == 0: - raise RuntimeError("No supported date text was found; document was not printed") -self._set_active_printer(printer_name) -self.safe_com_call(doc.PrintOut, False) -``` - -Count successful Word `Find.Execute` results in `replace_dates`. Treat inability to set `AutomationSecurity = 3` as initialization failure, allowing the existing cleanup to close COM safely. Update README wording to match the enforced behavior. - -- [ ] **Step 4: Run the focused Word processor suite** - -Run: `.venv/bin/pytest tests/test_word_processor.py -q` - ---- - -### Task 2: Preserve configuration and enforce main-only releases - -**Files:** -- Modify: `src/config.py` -- Test: `tests/test_config.py` -- Modify: `.github/workflows/build.yml` -- Test: `scripts/security-workflow-contract.test.mjs` - -**Interfaces:** -- Preserves: `_migrate_from(legacy_path: Path) -> Optional[AppConfig]` -- Produces: legacy rename only after `save(config)` succeeds -- Produces: release condition requiring `github.ref == 'refs/heads/main'` - -- [ ] **Step 1: Write failing migration and workflow-contract tests** - -```python -def test_failed_migration_write_keeps_legacy_config_for_retry(tmp_path): - manager = ConfigManager() - manager.config_file = tmp_path / "ShiftPress" / "config.json" - legacy_file = tmp_path / "ShiftPrint" / "config.json" - legacy_file.parent.mkdir(parents=True) - legacy_file.write_text('{"day_folder": "/old/day"}', encoding="utf-8") - manager.legacy_config_files = (legacy_file,) - with patch.object(manager, "save", side_effect=OSError("disk full")): - config = manager.load() - assert config.day_folder == "/old/day" - assert legacy_file.exists() -``` - -```javascript -assert.match(buildWorkflow, /github\.ref == 'refs\/heads\/main'/u); -``` - -- [ ] **Step 2: Run both focused tests and verify the old contracts fail** - -Run: `.venv/bin/pytest tests/test_config.py -k failed_migration_write -q` - -Run: `node --test scripts/security-workflow-contract.test.mjs` - -- [ ] **Step 3: Stop migration after a failed save and add the release ref guard** - -```python -try: - self.save(config) -except Exception as error: - logger.warning("Could not save migrated settings: %s", error) - return config -legacy_path.rename(legacy_path.with_suffix(".json.migrated")) -``` - -Require `github.ref == 'refs/heads/main'` in the Create Release step. - -- [ ] **Step 4: Run configuration and workflow tests** - -Run: `.venv/bin/pytest tests/test_config.py -q` - -Run: `node --test scripts/*.test.mjs` - ---- - -### Task 3: Make batch progress and final outcomes truthful - -**Files:** -- Modify: `src/main.py` -- Modify: `src/ui.py` -- Test: `tests/test_main.py` -- Test: `tests/test_ui.py` - -**Interfaces:** -- Produces: `_print_job(...) -> bool` -- Produces: `_cancel_ui_update(completed_jobs: int, total_jobs: int) -> None` -- Consumes: `ScheduleAppUI.update_status(message, progress, level=...)` - -- [ ] **Step 1: Write failing tests for progress timing and final states** - -```python -def test_partial_failure_ends_in_error_status(schedule_app, print_request): - schedule_app._print_job = Mock(side_effect=[True, False]) - schedule_app._process_batch(print_request) - run_scheduled_callbacks(schedule_app) - schedule_app.ui.update_status.assert_any_call( - "Completed with 1 failed schedule", 100, level="error" - ) -``` - -- [ ] **Step 2: Run focused tests and confirm the premature 100%/Complete behavior** - -Run: `.venv/bin/pytest tests/test_main.py tests/test_ui.py -k 'progress or partial_failure or cancel' -q` - -- [ ] **Step 3: Implement completed-job progress and outcome-specific copy** - -Before each Word call, show `Printing … (n/m)` at `completed / total`. After it returns, update to `(completed + 1) / total`. Use `level="success"` only when every job succeeds, `level="error"` for partial failure, and neutral `level="info"` for cancellation. Use correct `schedule`/`schedules` pluralization. - -- [ ] **Step 4: Run controller and UI suites** - -Run: `.venv/bin/pytest tests/test_main.py tests/test_ui.py -q` - ---- - -### Task 4: Make readiness and setup state honest and actionable - -**Files:** -- Modify: `src/ui.py` -- Test: `tests/test_ui.py` - -**Interfaces:** -- Produces: locally derived readiness blockers in `refresh_manifest_preview()` -- Produces: a three-line setup summary for Day templates, Night templates, and printer -- Preserves: controller preflight for filesystem contents, Word, and device availability - -- [ ] **Step 1: Write failing tests for every locally knowable blocker** - -Cover no enabled schedule, invalid ranges, an enabled schedule without its folder, no selected printer, and a missing `DateEntry` dependency. Assert that Print is disabled and the manifest card explains the first action required. Assert that a valid local selection uses neutral readiness copy rather than a green success claim. - -- [ ] **Step 2: Run the focused UI tests and confirm the old optimistic state** - -Run: `.venv/bin/pytest tests/test_ui.py -k 'readiness or setup_summary or missing_dateentry' -q` - -- [ ] **Step 3: Implement local readiness and explicit setup state** - -Derive blockers from enabled shifts, date-order validation, configured folders, printer selection, and dependency availability. Disable Print only while a blocker is present. Render separate Day templates, Night templates, and Printer lines in Setup. Refresh printer availability text whenever printers are refreshed. Rename the dialog action from Done to Close. - -- [ ] **Step 4: Run the UI suite** - -Run: `.venv/bin/pytest tests/test_ui.py -q` - ---- - -### Task 5: Add keyboard, focus, help, and resilient-state polish - -**Files:** -- Create: `.impeccable/config.json` -- Modify: `src/constants.py` -- Modify: `src/ui.py` -- Test: `tests/test_ui.py` - -**Interfaces:** -- Produces: `Alt+P` Print, `Alt+S` Setup, `Alt+H` How to use, and `Escape` to close Setup -- Produces: visible How to use action with concise task guidance -- Produces: explicit ttk focus styles and first-focus behavior in Setup -- Produces: hidden-until-active progress and wrapped long-state copy - -- [ ] **Step 1: Record code-first Impeccable configuration** - -Create `.impeccable/config.json` with `{"buildPath":"code"}`. - -- [ ] **Step 2: Write failing interaction and degraded-state tests** - -Assert root bindings for Print, Setup, and How to use; Escape binding and first focus in Setup; visible help action; focus-state style maps; hidden initial progress; and disabling with a direct dependency explanation when `DateEntry` is unavailable. - -- [ ] **Step 3: Run the focused interaction tests and confirm failure** - -Run: `.venv/bin/pytest tests/test_ui.py -k 'keyboard or focus or help or progress_visibility or dependency' -q` - -- [ ] **Step 4: Implement the smallest native Tkinter polish** - -Use Segoe UI Variable Display only for headings and Segoe UI Variable Text for body copy on Windows. Add blue, high-contrast focus treatment to interactive ttk styles. Add visible How to use and keyboard mnemonics. Focus the first Setup field, bind Escape, wrap long status and error copy, remove tooltip-only essential meaning, and reveal progress only while a run is active or has produced an outcome. - -- [ ] **Step 5: Run UI and constant tests** - -Run: `.venv/bin/pytest tests/test_ui.py tests/test_constants.py -q` - ---- - -### Task 6: Verify the whole product and re-score the frontend - -**Files:** -- Modify if runtime capture succeeds: `docs/screenshots/main.png` -- Update: `.impeccable/critique/2026-08-31T15-59-52Z__src-ui-py.md` - -- [ ] **Step 1: Run focused regression tests for all changed behaviors** - -Run: `.venv/bin/pytest tests/test_word_processor.py tests/test_config.py tests/test_main.py tests/test_ui.py -q` - -Run: `node --test scripts/*.test.mjs` - -- [ ] **Step 2: Inspect a real isolated native runtime** - -Launch ShiftPress with a disposable application-data directory. Inspect default, Setup, Help, invalid-range, and long-path states. Capture `docs/screenshots/main.png` only from that genuine runtime and only if capture is reliable; otherwise preserve the documented Windows smoke-test requirement. - -- [ ] **Step 3: Run the required single post-edit detector pass** - -Run: `node /Users/ryan/.agents/skills/impeccable/scripts/detect.mjs --json src/ui.py` - -- [ ] **Step 4: Run the complete repository gate** - -Run: `.venv/bin/black --check src tests` - -Run: `.venv/bin/mypy src --ignore-missing-imports` - -Run: `.venv/bin/pylint src --fail-under=8.0` - -Run: `.venv/bin/pytest --cov=src --cov-report=term-missing` - -Run: `node --test scripts/*.test.mjs` - -Run: `git diff --check` - -- [ ] **Step 5: Re-run the Impeccable critique** - -Run fresh frontend and detector assessments using the approved critique workflow, combine only verifiable evidence, and update the critique snapshot. Target 40/40. If a point depends on Windows Word COM or physical-printer evidence unavailable on this host, report that boundary instead of inferring success. - -- [ ] **Step 6: Review the final diff and hand off without publication** - -Inspect `git status --short --branch`, `git diff --stat`, and the changed source/test diff. Do not commit, push, merge, or dispatch a release. diff --git a/docs/superpowers/specs/2026-07-30-independent-shift-printing-design.md b/docs/superpowers/specs/2026-07-30-independent-shift-printing-design.md deleted file mode 100644 index 5037b9e..0000000 --- a/docs/superpowers/specs/2026-07-30-independent-shift-printing-design.md +++ /dev/null @@ -1,290 +0,0 @@ - - -# Independent Shift Printing and Native UI Redesign - -**Date:** 2026-07-30 - -**Status:** Approved - -**Surface:** `src/ui.py` and the print workflow it drives - -## Problem - -ShiftPress currently treats every selected date as two mandatory jobs. It -requires both template folders, preflights both shift types, calculates -`days × 2` jobs, and unconditionally prints Day followed by Night. In the real -workflow, an operator commonly prints the current Night schedule and the next -Day schedule in one run. The current model therefore prints schedules that may -already have been used or are not yet needed. - -Configured template folders must no longer imply print intent. - -## Goals - -- Make Night and Day independently selectable and independently dated. -- Support a single date or a date range for either shift. -- Default a fresh launch to Night today and Day tomorrow, both enabled and in - Single date mode. -- Keep both shift selections visible at the same time. -- Show the exact print manifest and document count before execution. -- Preserve existing template lookup, Word automation, cancellation, progress, - failure reporting, and configuration behavior. -- Implement the redesign entirely with standard Tkinter/ttk controls already - used by the project. -- Verify the complete automated suite and document the remaining Windows-only - smoke-test boundary. - -## Non-Goals - -- An arbitrary add/remove job queue. -- Tabs or modal steps that hide one shift. -- Custom-painted controls, Canvas gauges, animation, web rendering, or - image-based widgets. -- Rebranding ShiftPress or replacing its application icon. -- Live template scanning on every date-field edit. -- Changing Word document replacement or print semantics beyond selecting which - jobs reach them. - -## Approved Interface - -### Composition - -The default window becomes wider so two native shift groups can sit side by -side without crowding. The exact width is finalized against a rendered Tkinter -window and Windows display scaling; the generated composition is a hierarchy -reference, not a pixel specification. - -The vertical sequence is: - -1. Header and compact Setup group. -2. Side-by-side Night and Day selection groups. -3. Full-width print manifest. -4. Progress/status and one primary print action. - -The Setup group preserves separate Day Templates and Night Templates paths plus -the printer selector. The shift groups control print intent; the folder fields -only configure sources. - -### Shift groups - -Night and Day use the same native control structure: - -- `Include Night schedule` or `Include Day schedule` checkbutton. -- `Single date` and `Date range` radiobuttons. -- A `DateEntry` for Single date mode. -- Start and end `DateEntry` controls for Date range mode. -- Plain-language selected-document count. - -Night uses a narrow amber identity treatment and Day uses a narrow blue -identity treatment. Each group also contains the written shift name, so color -is never the only identifier. - -Disabling a shift: - -- removes it from validation, preflight, confirmation, progress, and printing; -- disables its mode and date controls; -- preserves its current values so re-enabling it is lossless. - -Switching modes preserves both the single-date value and the last range values. -Only the active mode contributes jobs. - -### Defaults and persistence - -Every application launch starts with: - -- Night enabled; -- Night in Single date mode using the local current date; -- Day enabled; -- Day in Single date mode using the following local calendar date. - -The app continues to persist template folders and printer selection. It does -not persist shift enablement, date modes, or dates because stale dates create a -print-safety risk and conflict with the daily default. - -### Manifest and action - -The full-width manifest is derived from the same immutable job list passed to -the worker thread. It states: - -- each enabled shift; -- its exact date or range; -- its document count; -- the selected printer; -- the total documents that will print. - -The primary button includes the actual count, for example -`Print 2 schedules`. When no shift is enabled, the action is blocked and a -plain validation message instructs the operator to include Night, Day, or both. - -The generated mockups show green `Ready` text for composition only. The -implementation must not claim templates are ready before the existing preflight -has actually succeeded. Before preflight, the UI reports selected document -counts rather than readiness. - -## Processing Model - -### Shift selections - -The controller collects one independent selection for Night and one for Day on -the Tkinter thread. Each selection contains: - -- shift type; -- enabled state; -- mode (`single` or `range`); -- active start and end dates; -- configured template folder. - -These values are collected before the worker starts so background processing -never reads Tkinter state. - -### Print manifest - -A pure manifest-building helper expands enabled selections into concrete print -jobs. Each job contains: - -- date; -- shift type; -- template name; -- template folder. - -Jobs are sorted chronologically. Night precedes Day only when both target the -same date. The manifest is the single source of truth for validation counts, -large-batch confirmation, progress, processing order, completion messaging, -and failure reporting. - -The existing `_compute_batch_size` assumption that every date produces two -jobs is removed or replaced with manifest-based counting. - -### Validation and preflight - -Validation proceeds in this order: - -1. Require at least one enabled shift. -2. Validate the active date or range for each enabled shift with the existing - range rules. -3. Validate the printer once. -4. Verify Word automation availability. -5. Validate only the folders belonging to enabled shifts. -6. Preflight only the templates required by the manifest. - -An empty or invalid folder for a disabled shift does not block printing. -Missing or ambiguous templates for an enabled shift fail before Word opens. - -Large-batch confirmation uses the actual manifest size and describes the -selected shift scopes rather than `days × 2 shifts`. - -### Execution, progress, and errors - -The worker receives the manifest and printer name. It: - -- saves the existing folder and printer configuration; -- reuses the preflight `WordProcessor` cache; -- iterates the manifest in its established order; -- checks cancellation before every document; -- updates progress as `completed jobs / manifest jobs`; -- records failures with the existing date, shift, template, and error fields; -- reports exact successes or failures against the manifest total; -- always resets the UI and releases Word resources. - -All selection controls, setup controls, and date widgets are disabled while a -batch is active. Existing Cancel and Escape behavior remains intact. - -## Error and Edge States - -- **Neither shift selected:** block before Word checks and explain how to select - at least one schedule. -- **Disabled shift has no folder:** ignore it. -- **Enabled shift has no folder:** identify that shift in the validation error. -- **Single date:** treat start and end as the same date. -- **Range end before start:** use the existing date-range error. -- **Selected template missing or ambiguous:** fail preflight with shift and - template identity. -- **Cancellation:** stop before the next manifest job and keep the existing - cancellation status. -- **Partial failure:** continue remaining jobs and write the existing CSV - report with accurate shift data. -- **High DPI or larger text:** keep both groups visible by allowing the native - window to auto-resize and remain user-resizable. - -## Testing - -### Controller and manifest tests - -- Night only, single date. -- Day only, single date. -- Night today plus Day tomorrow. -- Independent Night and Day ranges with different bounds. -- Chronological ordering and Night-before-Day tie breaking. -- Neither shift enabled. -- Disabled shift folder missing or invalid. -- Enabled shift folder missing or invalid. -- Preflight checks only enabled templates. -- Missing and ambiguous selected templates. -- Actual manifest counts in large-batch confirmation. -- Actual manifest totals in progress and completion messages. -- Cancellation before and between selected jobs. -- Failure reports retain the correct shift. - -### UI tests - -- Night defaults to enabled, Single date, and today. -- Day defaults to enabled, Single date, and tomorrow. -- Shift getters return independent selections. -- Include toggles enable and disable only their own controls. -- Mode changes expose or enable the correct date widgets. -- Values survive disable/re-enable and mode changes. -- `set_inputs_enabled` controls the new widgets during processing. -- Manifest copy and button count reflect the selected jobs. - -### Existing behavior - -The complete existing suite remains green, including scheduling, path -validation, configuration, logging, application paths, Word automation, and -date replacement tests. - -## Verification - -Local verification uses Python 3.12 and the repository's development -dependencies: - -```bash -black --check src tests -mypy src --ignore-missing-imports -pytest --cov=src --cov-report=term-missing -pylint src --fail-under=8.0 -``` - -The redesign also requires a rendered Tkinter inspection for clipping, focus -order, disabled states, and window scaling. - -Actual Microsoft Word COM automation and physical printer output cannot be -proven on macOS. Final operational verification therefore includes a Windows -smoke test using the built executable or a Python 3.12 environment: - -1. Night today plus Day tomorrow prints exactly two documents in manifest - order. -2. Night-only and Day-only each print exactly one selected document. -3. Independent ranges print the exact manifest count. -4. Disabled shifts do not require folders or templates. -5. Cancellation stops before the next document and Word exits cleanly. - -## Acceptance Criteria - -- The operator can independently include Night, Day, or both. -- Each enabled shift can use a single date or its own range. -- Fresh-launch defaults are Night today and Day tomorrow. -- Disabled shifts do not validate, preflight, count, or print. -- The manifest, confirmation, progress, completion, and failure output all use - the same concrete job list. -- The interface uses only standard Tkinter/ttk widgets. -- Both shift groups remain simultaneously visible in the approved side-by-side - composition. -- Automated tests and quality gates pass. -- The Windows smoke-test procedure is recorded and performed before claiming - end-to-end physical printing is verified. diff --git a/docs/superpowers/specs/2026-07-31-review-defect-remediation-design.md b/docs/superpowers/specs/2026-07-31-review-defect-remediation-design.md deleted file mode 100644 index 6b01781..0000000 --- a/docs/superpowers/specs/2026-07-31-review-defect-remediation-design.md +++ /dev/null @@ -1,345 +0,0 @@ -# Review Defect Remediation — Design - -Date: 2026-07-31 -Status: Approved - -## Problem - -A frontend-design and backend-functionality review of ShiftPress found fifteen -defects. The quality gates did not catch any of them: 219 tests pass, `black` -and `mypy` are clean, and `pylint` scores 8.85/10. Every defect below was -reproduced against a rendered instance of the UI or against live module code, -not inferred from reading. - -Three defects are user-visible on a normal launch (window can clip its own -primary action; saved template paths render as if unconfigured; the manifest -reads "1 schedules"). One defect can silently print the wrong schedule. One -allows a release to ship with failing tests. - -## Decisions - -Three decisions were open. All three are settled. - -1. **Template-name collisions block the run at preflight.** Consistent with the - existing multi-match `TemplateLookupError` and with product principle 5, - "Fail before printing when required inputs or templates are unavailable." - Accepted cost: an operator with a long-standing stray-space duplicate must - rename a file before printing. -2. **Window sizing is resolved by making both date modes the same height, then - deriving the window size from content.** This closes the decision the - Impeccable surface contract (`.impeccable/surfaces/src-ui-py.md`) explicitly - deferred: "Exact default window dimensions and final token values will be - resolved against the rendered Tkinter implementation and Windows display - scaling." Accepted cost: ~26px of quiet space in the single-date card. -3. **The stale screenshot is removed rather than replaced.** A genuine - replacement cannot be produced from this environment (macOS host, Windows - target, screen-recording permission denied). Fabricating one is not an - option. The README Preview section is removed and the capture step is - recorded in the Windows smoke-test doc. - -## Evidence - -Measured against a rendered `ScheduleAppUI`: - -``` -required height (single date) : 759 -required height (date range) : 785 -declared minsize : (1040, 720) - -at the allowed minimum 1040x720: - print button top=696 height=61 bottom=757 -> clipped by 37px of 61 - progress bar bottom=753 -> clipped by 33px -``` - -``` -_load_config inserts '/tmp/Day' - day_entry foreground : #B5B7BD (text_dim — placeholder gray) - expected : #F4F4F5 (text_main) -``` - -``` -files : Thursday.docx, Thursday .docx, THIRD Thursday.docx, Thursday Night.docx -cache : {'thursday': 'Thursday .docx', ...} # Thursday.docx silently gone -'Thursday' -> Thursday .docx # no error, no warning -``` - -``` -manifest title : 'This run: 1 schedules' -print button : 'Print 1 schedule' -``` - -``` -only the Night range is invalid; Day is untouched and valid - night panel reads: 'Check date selection' - day panel reads: 'Check date selection' # wrong shift flagged - error text : 'End date cannot be before start date' # names no shift -``` - -## Backend design - -### B1 — Template collision detection - -`WordProcessor._build_template_cache` normalizes each stem with -`" ".join(stem.lower().split())` and stores it in a `dict[str, str]`. Two files -whose stems normalize identically collapse to one key; the survivor depends on -directory iteration order. The existing ambiguity check never sees the -collision because it happens during cache construction. - -Change the cache to `dict[str, list[str]]`. `find_template_file` raises -`TemplateLookupError` when a matched key holds more than one path, listing the -colliding file names and instructing the operator to rename them. -`_preflight_templates` already converts `TemplateLookupError` into a blocking -validation error, so no new plumbing is required. - -The exact-match branch, the word-boundary matching branch, the `third` filter, -and the refresh-once-on-miss behaviour are all preserved. - -### B2 — `safe_com_call` retry precision - -`transient_keywords` currently includes the bare substring `"server"`, which -matches permanent faults such as "The server threw an exception". Each such -failure costs 5 retries at 1 second, per document. - -Replace with specific transient markers: `"call was rejected"`, -`"server is busy"`, `"message filter"`, `"rpc_e_"`. The retry count and delay -constants are unchanged. - -### B3 — Remove dead constants - -`WD_PRIMARY_HEADER_STORY`, `WD_EVEN_PAGES_HEADER_STORY`, -`WD_PRIMARY_FOOTER_STORY`, `WD_EVEN_PAGES_FOOTER_STORY`, -`WD_FIRST_PAGE_HEADER_STORY`, and `WD_FIRST_PAGE_FOOTER_STORY` are defined and -exported but imported nowhere. They are fossils of a removed header/footer-only -mode. Remove the definitions, the `__all__` entries, and the section comment. - -`WD_FIND_CONTINUE` and `WD_REPLACE_ALL` are in active use and stay. - -## Frontend design - -### F1 — Equal-height date modes and content-derived window size - -Today the single-date row is a label beside an entry (34px tall) and the range -rows are labels above entries (60px tall). Toggling modes changes the layout -height by 26px, and `_auto_resize_to_content()` runs only once at construction, -so the window never re-fits. - -Restructure the single-date row to label-above-entry, with the label `Date`, -matching the `Start date` / `End date` rows. Both modes then occupy the same -height by construction. No `grid_propagate` manipulation and no re-fit hook are -needed, and the two modes gain the same control structure that DESIGN.md -already requires across shifts. - -With content height constant, derive the window size from Tk's computed -requirement after widget construction: - -- initial geometry: `WINDOW_WIDTH` x computed required height -- `minsize`: `WINDOW_WIDTH` x computed required height - -`WINDOW_HEIGHT` and `WINDOW_MIN_HEIGHT` are no longer authoritative for height -and are removed, along with their `__all__` entries and their `src/ui.py` -imports. No test references either constant, so removal is contained. -`WINDOW_WIDTH` stays. `AUTO_RESIZE_MIN_WIDTH` and `AUTO_RESIZE_MIN_HEIGHT` -remain as the clamp floors for the screen-bound calculation. - -Because the derived height comes from the live font metrics of the running -system, this is correct under Windows text scaling, which a hardcoded constant -measured on macOS is not. - -### F2 — Folder-path setters own the placeholder contract - -`ShiftPressApp._load_config` reaches into `self.ui.day_entry` and calls -`.delete()` / `.insert()` directly. `_setup_placeholder` had already set the -entry foreground to `text_dim`, and nothing resets it, so a loaded path renders -in placeholder gray. `_browse_folder` handles this correctly, which is why the -bug only appears on the config path. - -Add `ScheduleAppUI.set_day_folder(path)` and `set_night_folder(path)`. These -own the invariant: clear the entry, insert the value, and set the foreground to -`text_main` for a real value or leave the placeholder in place for an empty -one. `_load_config` calls the setters instead of touching widget internals. - -This fixes the defect at the boundary. The controller stops manipulating widget -internals it does not own. - -### F3 — Manifest pluralization - -`refresh_manifest_preview` hardcodes `f"This run: {len(manifest)} schedules"`. -Use the same singular/plural logic the print button already applies: - -- 0 jobs: `This run: No schedules selected` (unchanged) -- 1 job: `This run: 1 schedule` -- N jobs: `This run: N schedules` - -### F4 — Semantic count-label states - -`NightCount.TLabel` and `DayCount.TLabel` are byte-identical, both hardcoded to -`foreground=COLORS.success`. Only the label text changes across states, so a -validation error and an excluded shift both render in success green. - -Replace both with three shared state styles: - -| State | Text | Style | Token | Hex | -| -------- | ----------------------------- | -------------------- | ---------- | --------- | -| Ready | `Selected · N document(s)` | `CountReady.TLabel` | `success` | `#4ADE80` | -| Excluded | `Not included` | `CountMuted.TLabel` | `text_dim` | `#B5B7BD` | -| Error | `Check date selection`| `CountError.TLabel` | `error` | `#FB7185` | - -`refresh_manifest_preview` sets both `text` and `style` together. - -Shift identity remains carried by the card title colour and border accent -(amber for Night, cyan for Day). The count line communicates readiness, not -identity, so it uses semantic state tokens. Text differs across all three -states, so colour reinforces rather than solely conveys state, satisfying -DESIGN.md's "Don't use color as the only indication of shift or validation -state." - -No existing test asserts `NightCount.TLabel` or `DayCount.TLabel`. - -### F5 — Per-shift error attribution - -`refresh_manifest_preview` calls `build_print_manifest(selections)`, which -raises on the first invalid selection. The `except ValueError` branch then -marks every enabled panel `Check date selection`, including shifts that are -perfectly valid, and the message `End date cannot be before start date` -originates in `validate_date_range` and names no shift. - -Add a pure method to `print_manifest.py`: - -```python -def validate(self) -> Optional[str]: - """Return a shift-labelled error, or None when this selection is valid.""" -``` - -It returns `None` for a disabled selection, reuses `active_range()` for the -missing-date cases (which already produce labelled messages), and wraps -`validate_date_range` failures as `f"{label} schedule: {error}"`. - -`refresh_manifest_preview` evaluates each selection independently: - -- valid enabled shift: `CountReady`, real document count -- invalid enabled shift: `CountError`, `Check date selection` -- disabled shift: `CountMuted`, `Not included` - -The manifest title names the offending shift. The body lists the labelled error -for each invalid shift. The print button falls back to `Print schedules` with -no count whenever any enabled shift is invalid, because `_validate_inputs` -blocks the whole run in that case; the button must not imply a printable count. - -`_validate_inputs` uses the same `validate()` method, replacing its own -inline range checking. This removes the current divergence where the controller -labels errors by shift and the preview does not. - -`build_print_manifest` continues to raise on invalid input. It remains the -last-line guard. - -## CI and release design - -### C1 — Build depends on quality - -The `build` job has no `needs:`, so on `workflow_dispatch` it runs in parallel -with `quality` and `action-gh-release` can publish with failing tests. Add -`needs: quality`. - -### C2 — Enforce the documented pylint gate - -README documents `pylint src --fail-under=8.0` as a quality gate; the workflow -does not run it. Add the step to the `quality` job. Current score is 8.85. - -### C3 — Single-source the version - -The workflow takes a hand-typed `version` input used for the artifact name and -release tag, while the in-app header reads `src/__init__.py.__version__`. -Nothing keeps them in sync. - -Add a `version` step to the `build` job that extracts `__version__` from -`src/__init__.py` and writes it to `$GITHUB_OUTPUT`: - -```yaml -- name: Read version - id: version - shell: bash - run: | - v=$(python -c "import re,pathlib; \ - print(re.search(r'__version__ = \"([^\"]+)\"', \ - pathlib.Path('src/__init__.py').read_text()).group(1))") - echo "value=$v" >> "$GITHUB_OUTPUT" -``` - -Both the artifact name and the release tag consume -`${{ steps.version.outputs.value }}`. Remove the `version` workflow input. The -`create_release` input stays. Releasing becomes: bump `__version__`, commit, -dispatch. - -## Docs and copy design - -- **D1.** Delete `docs/screenshots/main.png` and the README Preview section. Add - a screenshot-capture step to `docs/windows-smoke-test.md` so the refresh - happens where a real Windows instance exists. -- **D2.** Remove "Date replacement automation with optional header/footer-only - mode" from README Core Features. No such control exists and `replace_dates` - always spans all story ranges. -- **D3.** `_show_failure_summary` says "Click 'Open Logs' in the app footer"; - the button reads `Open logs`. Match the button. -- **D4.** The `main.py` comment claims "keyboard shortcuts (Enter = start, - Escape = cancel)". Enter only fires when the print button already has focus; - Escape is bound globally on the root. Correct the comment to say so. - -## Testing strategy - -Test-driven: each fix gets a failing test first, then the implementation. - -### Harness constraint - -`tests/test_ui.py` patches every `ttk` widget class and passes a -`MagicMock(spec=tk.Tk)` as the root. Real geometry is therefore unavailable in -the unit suite: `winfo_reqheight()` returns a `MagicMock`, not an integer. F1 -must not be tested by measuring rendered pixels there. It is split into two -testable contracts instead: - -- **Structure:** the single-date row is built with the same label-above-entry - shape as the range rows, asserted against the mocked construction calls. -- **Derivation:** with `root.winfo_reqheight.return_value` stubbed to a known - integer, `root.minsize` and `root.geometry` are called with that value. - -The rendered-pixel check stays a manual verification step (below), which is how -the defect was originally found. - -### New tests - -| Fix | Test | -| --- | --- | -| B1 | colliding normalized stems raise `TemplateLookupError` naming both files | -| B1 | a non-colliding folder still resolves exact and word-boundary matches | -| B2 | `"server threw an exception"` is not retried; `"server is busy"` is | -| F1 | the single-date row uses label-above-entry, matching the range rows | -| F1 | `minsize` and initial geometry use the computed required height | -| F2 | `set_day_folder` with a real path sets foreground to `text_main` | -| F2 | `set_day_folder("")` leaves the placeholder and dim foreground | -| F3 | a 1-job manifest renders `This run: 1 schedule` | -| F4 | each of the three states sets its matching style | -| F5 | an invalid Night range leaves the Day panel in `CountReady` | -| F5 | the error message names the offending shift | - -The 1-schedule case is currently untested; only 0-job and 2-job manifests are -covered, which is why F3 shipped. - -### Manual verification - -Against a real (unmocked) Tk instance on this host, confirm that the single and -range modes report equal required height, and that the window's `minsize` -equals its required height so the primary action cannot be clipped. The Windows -smoke test remains the release gate for real Word COM and printer behaviour. - -Regression bar: all 219 existing tests stay green, `black --check`, `mypy src`, -and `pylint src --fail-under=8.0` stay clean. - -## Out of scope - -- No unrelated refactoring of `src/ui.py` size or structure. -- The uncapped combined manifest size (each shift may span 366 days, so up to - 732 documents) is left as-is; `LARGE_BATCH_THRESHOLD` already prompts at 30. -- CSV failure reports are not hardened against spreadsheet formula injection. - The fields are computed template names and COM error strings, and this is a - single-operator desktop tool. -- No new Windows-only integration testing; the suite continues to mock - Windows-only modules. diff --git a/docs/superpowers/specs/2026-07-31-shiftprint-rename-design.md b/docs/superpowers/specs/2026-07-31-shiftprint-rename-design.md deleted file mode 100644 index 4d59f5b..0000000 --- a/docs/superpowers/specs/2026-07-31-shiftprint-rename-design.md +++ /dev/null @@ -1,189 +0,0 @@ -# ShiftPrint Rename and Icon — Design - -Date: 2026-07-31 -Status: Approved - -## Problem - -"ShiftPress" does not say what the application does. "Press" reads ambiguously — -printing press, press a button, compress — so someone meeting the name cold -cannot tell it prints shift schedules. The name is renamed to **ShiftPrint**, -which states both the domain (shift) and the action (print). - -The current icon has the same problem in a different form. It was designed in -March 2026 (`docs/plans/2026-03-03-icon-and-rename-design.md`) as a calendar page -with a press arrow in amber `#F59E0B` on zinc-900 — a palette that predates the -current design system and no longer matches it. Rendered at 16px, its date rows -blur into a grey smear and the arrow nearly disappears. - -## Decisions - -1. **Name: ShiftPrint.** Chosen over `PrintShift` (buries the domain behind the - verb, and "print shift" invites a Shift-key misreading) and `SchedulePrint` - (drops "shift", the app's defining concept of independent Day and Night). -2. **Full rename, including the data directory,** with config migration so no - operator loses their template folders or printer. -3. **Icon composition: the pair leads.** Two offset sheets, Night behind, Day in - front. Selected by the user over a single split page carrying a print arrow. - The pair states the product's actual differentiator — the two shifts are - independent objects, not one divided page. -4. **`DESIGN.md` gains an Icon section.** Explicitly approved as a durable system - change, which impeccable requires for an extension of an established world. - -### Process note - -Impeccable classifies this as extension of an established world, not creation of -a new one, so the visual world is inherited rather than reopened: the icon uses -existing `DESIGN.md` tokens and adds no new colour. Per `visualize.md`, three -compositional options were rendered and put before the user at one approval -point. The approved comp is `.impeccable/mocks/icon-2-two-sheets-approved.png`. - -## Icon specification - -Geometry is expressed as a fraction of the canvas so it is resolution -independent. The source renders at 1024×1024 and downsamples per size. - -| Element | Value | -| --- | --- | -| Ground tile | `#16171A`, full bleed, corner radius `0.12` | -| Night sheet | `#38BDF8`, origin `(0.195, 0.155)`, size `0.42 × 0.50`, radius `0.035` | -| Day sheet | `#F2B340`, origin `(0.385, 0.345)`, size `0.42 × 0.50`, radius `0.035` | -| Separation gap | `0.035` of canvas, drawn in ground colour behind the Day sheet | - -The tile radius follows `DESIGN.md`'s Shapes rule ("square or gently softened -corners; avoid large radii"). An initial 22% radius was rejected against that -rule. The system is flat: no shadow, gradient, or glow, per the Flat Native Rule. - -The gap and offset are not arbitrary. Three separations were rendered and read at -true 16px: at `0.020` the Night sheet collapses to a sliver, at `0.050` it -reduces to a bare corner, and `0.035` keeps both sheets distinct at 16px while -still reading as an overlapping pair at full size. - -### Outputs - -- `icon.png` — 1024×1024 RGBA -- `icon.ico` — 16, 24, 32, 48, 64, 128, 256 px - -These are the same filenames and the same size set as today, so no build -configuration changes. - -### What must not be literalized - -The comp is a 512px reference, not a trace target. If the production pipeline -shifts geometry, re-verify legibility at 16px rather than pixel-matching the -comp. No shadow or gradient may be introduced during production. - -### Generation and dependency - -The icon is drawn geometry, not a sourced raster, so it is reproducible from -code. A script at `tools/make_icon.py` renders both outputs from the table above, -and the generated `icon.png` and `icon.ico` are committed as assets. Anyone can -regenerate them without reverse-engineering a binary. - -This requires **Pillow**, which the project does not currently depend on. Pillow -is added to `requirements-dev.txt` only — never `requirements.txt`. The running -application loads `icon.ico` through Tkinter and needs no imaging library at -runtime; Pillow is needed to regenerate the asset and to run the icon tests -below, both of which are development concerns. - -## Rename inventory - -Occurrences of `ShiftPress` / `shiftpress`, verified by scan: - -| File | Occurrences | -| --- | --- | -| `src/__init__.py` | 7 | -| `src/main.py` | 6 | -| `src/ui.py` | 5 | -| `src/app_paths.py` | 4 | -| `src/logger.py` | 4 | -| `src/constants.py` | 2 | -| `src/config.py`, `src/path_validation.py`, `src/print_manifest.py`, `src/scheduler.py`, `src/word_processor.py` | 1 each | -| `main.py` | 2 | -| `.github/workflows/build.yml` | 4 | -| `setup.bat` | 1 | -| `start_app.bat` | 2 | -| `tests/test_logger.py` | 10 | -| `tests/test_main.py` | 6 | -| `tests/test_app_paths.py` | 2 | -| `tests/__init__.py` | 1 | - -Identity-bearing constants: - -``` -src/app_paths.py:12 APP_DIRNAME = "ShiftPress" -> "ShiftPrint" -src/constants.py:88 LOG_FILENAME = "shiftpress.log" -> "shiftprint.log" -``` - -`CONFIG_FILENAME` is `config.json` and does not carry the name; it is unchanged. - -Also updated: the window title, the PyInstaller `--name` in the workflow, and the -prose name in `README.md`, `DESIGN.md`, `PRODUCT.md`, `docs/windows-smoke-test.md`, -and `.impeccable/surfaces/src-ui-py.md`. - -`PRODUCT.md`'s brand commitment "Preserve the ShiftPress name" becomes "Preserve -the ShiftPrint name". Leaving it would contradict the code. - -### Historical documents are not rewritten - -`docs/plans/2026-03-03-icon-and-rename-design.md` and everything under -`docs/superpowers/specs/` and `docs/superpowers/plans/` are dated records of -decisions made under the old name. They are left as written. Retroactively -editing them would falsify the history of why the app is named what it is. The -new spec and plan use the new name; the old ones keep theirs. - -## Config migration - -Renaming `APP_DIRNAME` points `get_data_dir()` at `%APPDATA%\ShiftPrint`, which -is empty for every existing operator. Without migration they silently lose their -saved template folders and printer selection on first launch after upgrading. - -`ConfigManager` already solves this shape of problem: it detects a config at a -legacy path, loads it, saves it to the current path, and renames the original so -it is not picked up again. That mechanism is extended rather than duplicated. - -Design: - -- `app_paths.get_legacy_data_dir()` returns the previous per-user directory - (`%APPDATA%\ShiftPress` on Windows, `~/.shiftpress` elsewhere). -- On load, when the current config file does not exist, `ConfigManager` checks - the legacy data directory before falling back to defaults. -- A legacy config found there is loaded, written to the new path, and the - original renamed to `config.json.migrated`, matching existing behaviour. -- The existing working-directory legacy check is preserved; the new check is - additive, so both upgrade paths work. -- Migration failures are logged and fall back to defaults. A failed migration - must never prevent the app from starting. - -Log files are not migrated. They are diagnostic, rotate on their own, and the -old file remains readable in the old directory. - -## Testing - -Test-driven, following the existing suite's conventions. - -| Area | Test | -| --- | --- | -| Migration | a legacy-directory config is loaded when the new path is empty | -| Migration | the legacy file is renamed so it is not re-migrated | -| Migration | an existing new-path config wins over a legacy one | -| Migration | an unreadable legacy config falls back to defaults without raising | -| Paths | `get_data_dir()` and `get_legacy_data_dir()` differ on both platforms | -| Icon | `icon.ico` contains exactly the seven required sizes | -| Icon | `icon.png` is 1024×1024 RGBA | -| Name | no `ShiftPress` occurrence remains outside historical docs | - -Regression bar: the full suite stays green, `black --check`, `mypy src`, and -`pylint src --fail-under=8.0` stay clean, and both CI jobs pass — including -`windows-test`, which exercises the real pywin32 path where `%APPDATA%` -resolution actually runs. - -## Out of scope - -- **`gh repo rename`** and the local folder rename. Both are outward-facing, and - renaming the working directory would move this session out from under itself. - They are done last, deliberately, with the user present. -- **Version bump.** The rename does not change `__version__`; that is a separate - release decision. -- **Any UI change beyond the window title.** The interface keeps its current - layout, copy, and behaviour. diff --git a/docs/windows-smoke-test.md b/docs/windows-smoke-test.md index 8f17204..b95493d 100644 --- a/docs/windows-smoke-test.md +++ b/docs/windows-smoke-test.md @@ -1,66 +1,63 @@ # Windows Print Smoke Test -Use this checklist on a Windows workstation with Microsoft Word, the intended -printer, and representative Day and Night template folders. Unit tests mock the -Windows-only interfaces; this checklist verifies the real Word COM and printer -path before release. +Before release, run this checklist on Windows with Microsoft Word, the intended +physical printer, representative Day/Night template folders, and the release +candidate executable (or Python 3.12 via `start_app.bat`). Automated tests and +packaged startup checks do not establish physical printing. ## Preparation -1. Install dependencies and launch ShiftPress with `start_app.bat`. -2. Select **Setup…**, choose both template folders and the test printer, then - select **Apply**. Reopen Setup, make a harmless change, select **Cancel**, and - confirm the prior value is restored. -3. Confirm the collapsed Setup card says **Templates configured** and names the - selected printer without exposing either folder path. -4. Use dates that have known Day and Night templates. Keep the printed pages for - comparison with the manifest. - -## Required Runs - -For every run, compare the numbered **Print scope** manifest with both Word activity -and the physical printer output. - -| Run | Night selection | Day selection | Expected result | -| --- | --- | --- | --- | -| Night only | Enabled, one date | Disabled | Exactly one Night document prints. No Day document opens or prints. | -| Day only | Disabled | Enabled, one date | Exactly one Day document prints. No Night document opens or prints. | -| Both | Enabled, one date | Enabled, one date | Exactly the two listed documents print in manifest order. | -| Mixed scope | Enabled, date range | Enabled, one date | Night prints every listed range document; Day prints only its listed date. | - -## State and Safety Checks - -- Change Night dates and confirm Day dates do not change; then repeat in the - other direction. -- Disable either shift and confirm its card says **Not included**, the manifest - removes it, and the button count decreases before printing. -- Disable both shifts and confirm printing is blocked. -- Select an invalid range for one enabled shift and confirm no documents print. -- Start a multi-document run, select **Cancel**, and confirm no unlisted - documents print after cancellation takes effect. -- Confirm the progress percentage and final status use the manifest document - count, not a fixed Day-plus-Night multiplier. -- Confirm missing-template and Word/printer failures name the affected shift and - date, preserve the source documents, and appear in the failure report. - -## Result - -Record the Windows version, Word version, printer name, application commit, and -pass/fail result for each required run. A release is print-verified only after -all four runs and the state and safety checks pass on Windows. - -## Refresh the README screenshot - -The README has no screenshot. Capture one here, where a real Windows instance -with Microsoft Word exists: - -1. Launch ShiftPress with both template folders configured and a printer - selected. -2. Capture the main window to `docs/screenshots/main.png`. -3. Restore the README `## Preview` section above `## Core Features`: - - ```markdown - ## Preview - - ![Main window](docs/screenshots/main.png) - ``` +1. Open **Setup…**, choose template folders and printer, then **Apply**. Reopen + Setup, change a value, and **Cancel**; verify the prior settings return. +2. Confirm the summary identifies Night source, Day source, and printer + separately with recognizable folder identities. Configured paths must not + imply template or printer-device readiness. +3. Choose dates with known templates. Keep source copies and printed pages for + comparison with the numbered **Print scope** manifest and Word activity. + +## Required runs + +| Run | Night | Day | Physical output | +| ----------- | ---------- | -------- | ------------------------------------------------------- | +| Night only | One date | Disabled | One Night document; no Day document opens or prints. | +| Day only | Disabled | One date | One Day document; no Night document opens or prints. | +| Both | One date | One date | Exactly two listed documents in manifest order. | +| Mixed scope | Date range | One date | Every listed Night date and only the selected Day date. | + +## State and safety + +- Fresh launch and **Reset run** select Night today and Day tomorrow. Changes + to one shift never change the other; values survive mode changes and + disabling/re-enabling. +- Disable either shift: its state becomes **Not included**, its manifest jobs + disappear, and the Print count decreases. Its missing/invalid folder must not + block the enabled shift. Disabling both blocks printing. +- An invalid enabled range blocks all printing and identifies that shift. + Missing/ambiguous selected templates fail before document processing. +- During a multi-document run, selection/setup controls are locked. Cancel + stops before the next document after an active Word call finishes; Word + resources are released. No unlisted document prints. +- Progress advances after each attempt and uses the manifest total. Final state + distinguishes all-success, partial failure, and cancellation with exact + counts. Compare sent-to-printer claims with the actual pages. +- Check body/header/footer dates. Source documents remain unchanged. A template + with no supported date text and an unavailable requested printer must not + print; macro-disable failure must block Word initialization. +- Failures identify the affected shift/date; failed document attempts appear in + CSV reports. Settings-save failures are visible before printing and on close; + logs retain diagnostic detail. +- Inspect main/Setup at Windows text scaling and constrained display size: + no unreachable controls, keyboard focus follows scrolling, long values wrap, + and shortcuts, Help, Apply/Cancel, and Escape behave as described. +- Switch between **Midnight** and **Rose**. Main, Setup, and calendars remain + dark, selections and processing locks are unchanged, and the theme restores + after closing/reopening. There is no light or system-following theme. + +## Evidence + +Record tester/date, Windows and Word versions, printer, exact application commit +and artifact, and pass/fail for each run and safety check. A release is +print-verified only after every required check passes with physical output. +Keep evidence in the release/task record rather than a new repository report. +Any screenshot added to README must show this genuine current Windows runtime; +do not reuse an obsolete screenshot or fabricate one. diff --git a/icon.ico b/icon.ico index e7018eb..cad4339 100644 Binary files a/icon.ico and b/icon.ico differ diff --git a/icon.png b/icon.png index 432ad90..13602c6 100644 Binary files a/icon.png and b/icon.png differ diff --git a/src/__init__.py b/src/__init__.py index 2e8c1ad..807217d 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -12,7 +12,7 @@ - Per-user data paths (app_paths) """ -__version__ = "3.0.0" +__version__ = "3.1.0" __author__ = "ShiftPress" __all__ = ["ShiftPressApp", "main"] diff --git a/src/config.py b/src/config.py index a0b6a08..5f419d9 100644 --- a/src/config.py +++ b/src/config.py @@ -27,13 +27,14 @@ class AppConfig: day_folder: str = "" night_folder: str = "" printer_name: str = "" + theme: str = "midnight" def to_dict(self) -> dict[str, Any]: """Convert config to dictionary. Returns: Dict with keys ``day_folder``, ``night_folder``, - and ``printer_name``. + ``printer_name``, and the dark color ``theme``. """ return asdict(self) @@ -52,6 +53,7 @@ def from_dict(cls, data: dict[str, Any]) -> "AppConfig": day_folder=str(data.get("day_folder", "") or ""), night_folder=str(data.get("night_folder", "") or ""), printer_name=str(data.get("printer_name", "") or ""), + theme="rose" if data.get("theme") == "rose" else "midnight", ) diff --git a/src/constants.py b/src/constants.py index b5e7993..312923a 100644 --- a/src/constants.py +++ b/src/constants.py @@ -5,7 +5,7 @@ to avoid magic numbers and strings. """ -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Final, Union __all__ = [ @@ -46,6 +46,7 @@ "AUTO_RESIZE_MIN_HEIGHT", "COLORS", "FONTS", + "THEMES", ] # Weekday constants (Python's datetime.weekday() returns 0=Monday, 6=Sunday) @@ -88,7 +89,7 @@ LOG_FILENAME: Final = "shiftpress.log" # UI Constants -WINDOW_WIDTH: Final = 1040 +WINDOW_WIDTH: Final = 1120 WINDOW_RESIZABLE: Final = True # Progress bar @@ -124,21 +125,25 @@ class Colors: """Color scheme constants for the application UI.""" - background: str = "#16171A" # Window charcoal - surface: str = "#24262A" # Raised graphite work surface - input: str = "#1B1C20" # Recessed native input surface + background: str = "#091422" + surface: str = "#0C1826" + input: str = "#131C27" accent: str = "#F2B340" # Press amber — Setup title and selection chrome - night_accent: str = "#38BDF8" # Sky-400 — Night shift identity (night sky) - day_accent: str = "#F2B340" # Press amber — Day shift identity (daylight) - action: str = "#1267C9" # Saturated print-action blue - action_hover: str = "#0F56A8" # Deeper action blue - text_main: str = "#F4F4F5" # Zinc-100 — warm off-white - text_dim: str = "#B5B7BD" # High-contrast supporting text + night_accent: str = "#8BCFF5" + day_accent: str = "#ECC17E" + action: str = "#8EBBFF" + action_hover: str = "#B0CFFF" + action_text: str = "#102038" + text_main: str = "#F2F6FC" + text_dim: str = "#B4C2D4" success: str = "#4ADE80" # Emerald-400 error: str = "#FB7185" # Rose-400 — softer red - border: str = "#5A5D64" # Divider steel - secondary: str = "#303238" # Secondary controls and hover states + border: str = "#44556B" + secondary: str = "#29374A" accent_hover: str = "#D99A25" # Deeper amber press state + header: str = "#081221" + night_surface: str = "#203248" + day_surface: str = "#332D24" FontSpec = Union[tuple[str, int], tuple[str, int, str]] @@ -153,12 +158,12 @@ def _font_families() -> tuple[str, str]: import sys if sys.platform == "darwin": - return "SF Pro Text", "SF Pro Display" + return "Avenir Next", "Avenir Next" if sys.platform.startswith("linux"): return "Ubuntu", "Ubuntu" - # Windows — use the optical role intended for each size. Tkinter silently - # falls back to Segoe UI when the variable families are unavailable. - return "Segoe UI Variable Text", "Segoe UI Variable Display" + # Both ship with supported Windows 10/11 installations. Bahnschrift lends + # the schedule headings a precise, industrial voice; Segoe keeps forms familiar. + return "Segoe UI", "Bahnschrift" _BODY_FONT_FAMILY, _DISPLAY_FONT_FAMILY = _font_families() @@ -171,6 +176,8 @@ class Fonts: main: FontSpec = (_BODY_FONT_FAMILY, 11) bold: FontSpec = (_BODY_FONT_FAMILY, 11, "bold") header: FontSpec = (_DISPLAY_FONT_FAMILY, 24, "bold") + brand: FontSpec = (_DISPLAY_FONT_FAMILY, 29, "bold") + section: FontSpec = (_DISPLAY_FONT_FAMILY, 18, "bold") card_title: FontSpec = (_DISPLAY_FONT_FAMILY, 13, "bold") sub: FontSpec = (_BODY_FONT_FAMILY, 10) button: FontSpec = (_BODY_FONT_FAMILY, 14, "bold") @@ -178,4 +185,25 @@ class Fonts: # Global color and font instances COLORS = Colors() +THEMES = { + "midnight": COLORS, + "rose": replace( + COLORS, + background="#15131B", + surface="#171620", + input="#191620", + header="#160F1E", + border="#514558", + text_main="#F8F3F8", + text_dim="#C0ADC3", + action="#E59AC8", + action_hover="#F0B9DC", + action_text="#251522", + secondary="#352C3D", + night_accent="#B7B5ED", + day_accent="#E9B77E", + night_surface="#252339", + day_surface="#35272B", + ), +} FONTS = Fonts() diff --git a/src/main.py b/src/main.py index c67c8c1..de07dd5 100644 --- a/src/main.py +++ b/src/main.py @@ -26,7 +26,7 @@ from .logger import setup_logging, get_logger from .path_validation import validate_folder_path from .print_manifest import PrintJob, ShiftSelection, build_print_manifest -from .ui import ScheduleAppUI +from .ui import ScheduleAppUI, configure_windows_dpi from .word_processor import ( WordProcessor, TemplateLookupError, @@ -63,6 +63,7 @@ class _BatchRequest: printer_name: str day_folder: str night_folder: str + theme: str = "midnight" def _batch_stop_status(completed_jobs: int, total_jobs: int) -> str: @@ -137,6 +138,7 @@ def _load_config(self) -> None: """Load configuration and apply to UI.""" try: config = self.config_manager.load() + self.ui.set_theme(config.theme) self.ui.set_day_folder(config.day_folder) self.ui.set_night_folder(config.night_folder) if config.printer_name and self.ui.printer_var: @@ -226,6 +228,7 @@ def _validate_inputs( printer_name=printer_name, day_folder=folders.get("day", ""), night_folder=folders.get("night", ""), + theme=self.ui.get_theme(), ), None, ) @@ -495,6 +498,7 @@ def _persist_batch_config(self, request: _BatchRequest) -> None: day_folder=request.day_folder, night_folder=request.night_folder, printer_name=request.printer_name, + theme=request.theme, ) if not self._save_config(config): self._safe_after( @@ -522,9 +526,6 @@ def _show_batch_outcome( level="error", ) ) - report_path = self._write_failure_report(failed_operations) - snapshot = list(failed_operations) - self._safe_after(lambda: self._show_failure_summary(snapshot, report_path)) return schedule_noun = "schedule" if total_jobs == 1 else "schedules" @@ -601,6 +602,14 @@ def _process_batch(self, request: _BatchRequest) -> None: logger.exception("Error during batch processing") self._show_batch_error(completed_jobs, total_jobs) finally: + # Retain attempted-job failures on every exit, including cancellation + # and window close. _safe_after skips only the UI when closing. + if failed_operations: + report_path = self._write_failure_report(failed_operations) + snapshot = list(failed_operations) + self._safe_after( + lambda: self._show_failure_summary(snapshot, report_path) + ) self._safe_after(self._reset_ui) def _on_close(self) -> None: @@ -630,6 +639,7 @@ def _on_close(self) -> None: day_folder=(self.ui.get_day_folder() or "").strip(), night_folder=(self.ui.get_night_folder() or "").strip(), printer_name=printer, + theme=self.ui.get_theme(), ) if not self._save_config(config): self.ui.show_warning( @@ -674,7 +684,7 @@ def _show_failure_summary( message += f"\n\nLog file:\n{log_path}" message += "\n\nTip: Click 'Open logs' in the app footer." - self.ui.show_warning("Processing Completed with Errors", message) + self.ui.show_warning("Print failures", message) def _write_failure_report( self, failed_operations: list[FailedOperation] @@ -720,6 +730,7 @@ def main() -> None: logger.info("Starting ShiftPress") try: + configure_windows_dpi() root = tk.Tk() app = ShiftPressApp(root) app.ui.run() diff --git a/src/ui.py b/src/ui.py index cc77e38..6ea08c9 100644 --- a/src/ui.py +++ b/src/ui.py @@ -21,7 +21,7 @@ except Exception: # pragma: no cover DateEntry = None -from typing import Optional, Callable, Literal, Any, cast +from typing import Optional, Callable, Literal, Any, cast, overload try: import win32print # type: ignore @@ -30,6 +30,7 @@ from .constants import ( COLORS, + THEMES, FONTS, WINDOW_WIDTH, WINDOW_RESIZABLE, @@ -66,11 +67,14 @@ # Reused Tk style and tkcalendar tokens. Keeping these names centralized avoids # accidental drift between style definitions and widget construction. _STYLE_CARD_FRAME = "Card.TFrame" +_STYLE_CARD_LABEL = "Card.TLabel" _STYLE_CARD_SUB_LABEL = "CardSub.TLabel" _STYLE_TERTIARY_BUTTON = "Tertiary.TButton" _STYLE_PRIMARY_BUTTON = "Primary.TButton" _STYLE_DANGER_BUTTON = "Danger.TButton" _STYLE_HEADER_LABEL = "Header.TLabel" +_STYLE_HEADER_FRAME = "Header.TFrame" +_STYLE_HEADER_SUB_LABEL = "HeaderSub.TLabel" _STYLE_SUB_LABEL = "Sub.TLabel" _STYLE_COUNT_SELECTED_LABEL = "CountSelected.TLabel" _STYLE_SUCCESS_LABEL = "Success.TLabel" @@ -81,7 +85,9 @@ _DATE_PATTERN = "mm/dd/yyyy" _FOCUS_IN_EVENT = "" _CONFIGURE_EVENT = "" +_ESCAPE_EVENT = "" _PRINT_BUTTON_LABEL = "Print schedules" +_NOT_CONFIGURED_LABEL = "Not configured" def _status_style( @@ -132,6 +138,20 @@ def _manifest_blocker( return None +def configure_windows_dpi() -> None: + """Use sharp system-DPI rendering; call before constructing the Tk root.""" + if sys.platform != "win32": + return + try: + # System-aware (-2) suits Tk's fixed point scaling. A packaged manifest + # may already set awareness, in which case Windows keeps that setting. + getattr(ctypes, "windll").user32.SetProcessDpiAwarenessContext( + ctypes.c_void_p(-2) + ) + except (AttributeError, OSError): + logger.debug("Windows DPI configuration unavailable", exc_info=True) + + def _get_work_area(window: tk.Misc) -> tuple[int, int, int, int]: """Return usable screen bounds, excluding the Windows taskbar when possible.""" screen_width = window.winfo_screenwidth() @@ -227,9 +247,18 @@ def __init__(self, root: tk.Tk, today: Optional[date] = None): today: Optional deterministic date used for launch defaults. """ self.root = root + self._scale = ( + max(1.0, float(root.winfo_fpixels("1i")) / 96) + if sys.platform == "win32" + else 1.0 + ) + _, work_top, _, work_bottom = _get_work_area(root) + self._compact_layout = work_bottom - work_top < self._px(960) + self.colors = COLORS + self.theme_var = tk.StringVar(value="Midnight") self.root.title("ShiftPress") self.root.resizable(WINDOW_RESIZABLE, WINDOW_RESIZABLE) - self.root.configure(bg=COLORS.background) + self.root.configure(bg=self.colors.background) self._today = today or date.today() self._inputs_enabled = True self._dependency_error: Optional[str] = None @@ -246,7 +275,13 @@ def __init__(self, root: tk.Tk, today: Optional[date] = None): self.day_entry: Optional[ttk.Entry] = None self.night_entry: Optional[ttk.Entry] = None self.printer_var: Optional[tk.StringVar] = None - self.setup_summary_label: Optional[ttk.Label] = None + self._setup_values: dict[str, ttk.Label] = {} + self._theme_picker: Optional[ttk.Menubutton] = None + self._theme_menu: Optional[tk.Menu] = None + self._help_dialog: Optional[tk.Toplevel] = None + self._help_text: Optional[tk.Text] = None + self._help_button: Optional[ttk.Button] = None + self._manifest_printer_label: Optional[ttk.Label] = None self._setup_details: Optional[ttk.Frame] = None self._setup_dialog: Optional[tk.Toplevel] = None self._setup_canvas: Optional[tk.Canvas] = None @@ -255,6 +290,7 @@ def __init__(self, root: tk.Tk, today: Optional[date] = None): self._setup_scrollbar: Optional[ttk.Scrollbar] = None self._setup_snapshot: Optional[tuple[str, str, str]] = None self._setup_toggle_btn: Optional[ttk.Button] = None + self._reset_btn: Optional[ttk.Button] = None self._manifest_card: Optional[ttk.Frame] = None self._footer_frame: Optional[ttk.Frame] = None self._logs_btn: Optional[ttk.Button] = None @@ -287,310 +323,540 @@ def __init__(self, root: tk.Tk, today: Optional[date] = None): # Center the window on screen after final sizing. self._center_window() + self._style_titlebar(self.root) logger.info("UI initialized") + def get_theme(self) -> str: + """Return the persisted identifier of the selected dark palette.""" + return self.theme_var.get().lower() + + def _px(self, value: int) -> int: + """Scale layout pixels alongside Windows' native point-sized text.""" + return round(value * self._scale) + + def _gap(self, value: int) -> int: + """Use less vertical whitespace on short work areas, never smaller text.""" + return self._px(round(value * 0.65) if self._compact_layout else value) + + @overload + def _spacing(self, first: int, second: int, /) -> tuple[int, int]: ... + + @overload + def _spacing( + self, first: int, second: int, third: int, fourth: int, / + ) -> tuple[int, int, int, int]: ... + + def _spacing(self, *values: int) -> tuple[int, ...]: + """Scale native pack/grid/style spacing using the same design units.""" + return tuple(self._px(value) for value in values) + + def set_theme(self, theme: str) -> None: + """Restyle existing controls without changing dates, focus, or run state.""" + selected = theme if theme in THEMES else "midnight" + self.theme_var.set(selected.title()) + self.colors = THEMES[selected] + self._configure_styles() + for widget in ( + self.root, + self._main_canvas, + self._setup_canvas, + self._setup_dialog, + ): + if widget is not None: + widget.configure(background=self.colors.background) + for shift, panel in self._shift_panels.items(): + accent = ( + self.colors.night_accent if shift == "night" else self.colors.day_accent + ) + for picker in ( + panel.single_picker, + panel.range_start_picker, + panel.range_end_picker, + ): + picker.configure(**self._calendar_kwargs(accent)) + for entry in (self.day_entry, self.night_entry): + if entry is None: + continue + color = ( + self.colors.text_dim + if entry.get() == _PATH_PLACEHOLDER + else self.colors.text_main + ) + entry.configure(foreground=color) + self._style_printer_menu() + self._style_theme_menu() + self._style_help_dialog() + self._style_titlebar(self.root) + if self._setup_dialog is not None: + self._style_titlebar(self._setup_dialog) + + def _style_titlebar(self, window: tk.Tk | tk.Toplevel) -> None: + """Keep Windows captions dark independently of the system color theme.""" + if sys.platform != "win32": + return + try: + window.update_idletasks() + dlls = getattr(ctypes, "windll") + get_parent = dlls.user32.GetParent + get_parent.argtypes = [wintypes.HWND] + get_parent.restype = wintypes.HWND + handle = get_parent(window.winfo_id()) + setter = dlls.dwmapi.DwmSetWindowAttribute + setter.argtypes = [ + wintypes.HWND, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + ] + dark = wintypes.BOOL(1) + setter(handle, 20, ctypes.byref(dark), ctypes.sizeof(dark)) + # Explicit caption/text colors cover Windows 11 even in OS light + # mode. Older versions ignore unsupported attributes safely. + for attribute, color in ( + (35, self.colors.header), + (36, self.colors.text_main), + ): + rgb = color[1:] + value = wintypes.DWORD(int(rgb[4:6] + rgb[2:4] + rgb[0:2], 16)) + setter(handle, attribute, ctypes.byref(value), ctypes.sizeof(value)) + except (AttributeError, OSError): + logger.debug("Native dark caption unavailable", exc_info=True) + def _configure_styles(self) -> None: """Configure ttk styles for the application.""" # Base frame - self.style.configure("TFrame", background=COLORS.background) + self.style.configure("TFrame", background=self.colors.background) self.style.configure( "TLabel", - background=COLORS.background, - foreground=COLORS.text_main, + background=self.colors.background, + foreground=self.colors.text_main, font=FONTS.main, ) - self.style.configure(_STYLE_CARD_FRAME, background=COLORS.surface) + self.style.configure(_STYLE_CARD_FRAME, background=self.colors.surface) + self.style.configure(_STYLE_HEADER_FRAME, background=self.colors.header) self.style.configure( - "Card.TLabel", - background=COLORS.surface, - foreground=COLORS.text_main, + "Brand.TLabel", + background=self.colors.header, + foreground=self.colors.text_main, + font=FONTS.brand, + ) + self.style.configure( + _STYLE_HEADER_SUB_LABEL, + background=self.colors.header, + foreground=self.colors.action, + font=FONTS.main, + ) + self.style.configure( + "Section.TLabel", + background=self.colors.background, + foreground=self.colors.text_main, + font=FONTS.section, + ) + self.style.configure( + "SetupValue.TLabel", + background=self.colors.surface, + foreground=self.colors.action, + font=FONTS.main, + ) + self.style.configure( + _STYLE_CARD_LABEL, + background=self.colors.surface, + foreground=self.colors.text_main, font=FONTS.main, ) self.style.configure( _STYLE_CARD_SUB_LABEL, - background=COLORS.surface, - foreground=COLORS.text_dim, + background=self.colors.surface, + foreground=self.colors.text_dim, font=FONTS.sub, ) # Native card shells avoid LabelFrame's platform-specific title patches. for style_name, border_color in ( - ("SetupCard.TFrame", COLORS.border), - ("NightCard.TFrame", COLORS.night_accent), - ("DayCard.TFrame", COLORS.day_accent), - ("Manifest.TFrame", COLORS.border), - ("DialogCard.TFrame", COLORS.border), + ("SetupCard.TFrame", self.colors.border), + ("NightCard.TFrame", self.colors.night_accent), + ("DayCard.TFrame", self.colors.day_accent), + ("Manifest.TFrame", self.colors.border), + ("DialogCard.TFrame", self.colors.border), ): self.style.configure( style_name, - background=COLORS.surface, + background=self.colors.surface, bordercolor=border_color, borderwidth=1, relief="solid", ) self.style.configure( "SetupTitle.TLabel", - background=COLORS.background, - foreground=COLORS.text_main, + background=self.colors.surface, + foreground=self.colors.text_main, font=FONTS.card_title, ) self.style.configure( "NightTitle.TLabel", - background=COLORS.background, - foreground=COLORS.night_accent, + background=self.colors.night_surface, + foreground=self.colors.night_accent, font=FONTS.card_title, ) self.style.configure( "DayTitle.TLabel", - background=COLORS.background, - foreground=COLORS.day_accent, + background=self.colors.day_surface, + foreground=self.colors.day_accent, font=FONTS.card_title, ) - self.style.configure("SetupHeader.TSeparator", background=COLORS.border) - self.style.configure("NightHeader.TSeparator", background=COLORS.night_accent) - self.style.configure("DayHeader.TSeparator", background=COLORS.day_accent) + self.style.configure("SetupHeader.TSeparator", background=self.colors.border) + self.style.configure( + "NightHeader.TSeparator", background=self.colors.night_accent + ) + self.style.configure("DayHeader.TSeparator", background=self.colors.day_accent) + self.style.configure("NightHeader.TFrame", background=self.colors.night_surface) + self.style.configure("DayHeader.TFrame", background=self.colors.day_surface) # Inputs self.style.configure( "TEntry", - fieldbackground=COLORS.input, - foreground=COLORS.text_main, - insertcolor=COLORS.text_main, - selectbackground=COLORS.action, - selectforeground=COLORS.text_main, - bordercolor=COLORS.border, + fieldbackground=self.colors.input, + foreground=self.colors.text_main, + insertcolor=self.colors.text_main, + selectbackground=self.colors.action, + selectforeground=self.colors.action_text, + bordercolor=self.colors.border, + lightcolor=self.colors.border, + darkcolor=self.colors.border, borderwidth=1, - padding=(8, 7), + padding=self._spacing(8, 7), ) self.style.map( "TEntry", - fieldbackground=[("disabled", COLORS.border)], - foreground=[("disabled", COLORS.text_dim)], - ) - # DateEntry copies TCombobox into its own arrow-bearing style. - self.style.configure( - "TCombobox", - fieldbackground=COLORS.input, - background=COLORS.secondary, - foreground=COLORS.text_main, - arrowcolor=COLORS.text_main, - bordercolor=COLORS.border, - lightcolor=COLORS.border, - darkcolor=COLORS.border, - padding=(8, 7), - ) - self.style.map( - "TCombobox", - fieldbackground=[ - ("readonly", COLORS.input), - ("disabled", COLORS.surface), - ], - foreground=[("disabled", COLORS.text_dim)], - arrowcolor=[("disabled", COLORS.text_dim)], - ) + fieldbackground=[("disabled", self.colors.border)], + foreground=[("disabled", self.colors.text_dim)], + ) + # One native field/arrow layout for theme, printer, and date choices. + # DateEntry copies TCombobox, including this integrated arrow layout. + for style_name, content in ( + ("TCombobox", "Combobox.textarea"), + ("TMenubutton", "Menubutton.label"), + ): + self.style.layout( + style_name, + [ + ( + "Entry.field", + { + "sticky": "nswe", + "children": [ + ( + "Menubutton.indicator", + {"side": "right", "sticky": "ns"}, + ), + ( + "Combobox.padding", + { + "sticky": "nswe", + "children": [ + (content, {"sticky": "nswe"}), + ], + }, + ), + ], + }, + ) + ], + ) + self.style.configure( + style_name, + fieldbackground=self.colors.input, + background=self.colors.input, + foreground=self.colors.text_main, + font=FONTS.main, + arrowcolor=self.colors.action, + arrowsize=self._px(5), + arrowpadding=self._px(10), + selectbackground=self.colors.input, + selectforeground=self.colors.text_main, + bordercolor=self.colors.border, + lightcolor=self.colors.input, + darkcolor=self.colors.input, + padding=self._spacing(12, 8), + ) + self.style.map( + style_name, + fieldbackground=[ + ("disabled", self.colors.surface), + ("active", self.colors.secondary), + ("readonly", self.colors.input), + ], + foreground=[("disabled", self.colors.text_dim)], + arrowcolor=[("disabled", self.colors.text_dim)], + background=[("active", self.colors.secondary)], + selectbackground=[("readonly", self.colors.input)], + selectforeground=[("readonly", self.colors.text_main)], + ) # Buttons self.style.configure( "TButton", - background=COLORS.secondary, - foreground=COLORS.text_main, - bordercolor=COLORS.border, + background=self.colors.secondary, + foreground=self.colors.text_main, + bordercolor=self.colors.border, + lightcolor=self.colors.secondary, + darkcolor=self.colors.secondary, borderwidth=1, font=FONTS.bold, - padding=(14, 8), + padding=self._spacing(14, 8), ) self.style.map( "TButton", background=[ - ("disabled", COLORS.border), - ("pressed", COLORS.background), - ("active", COLORS.secondary), + ("disabled", self.colors.border), + ("pressed", self.colors.background), + ("active", self.colors.secondary), ], - foreground=[("disabled", COLORS.text_dim)], + foreground=[("disabled", self.colors.text_dim)], ) self.style.configure( _STYLE_TERTIARY_BUTTON, - background=COLORS.background, - foreground=COLORS.text_dim, + background=self.colors.background, + foreground=self.colors.action, borderwidth=0, font=FONTS.sub, - padding=(6, 2), + padding=self._spacing(6, 2), ) self.style.map( _STYLE_TERTIARY_BUTTON, background=[ - ("pressed", COLORS.background), - ("active", COLORS.background), + ("pressed", self.colors.background), + ("active", self.colors.background), ], foreground=[ - ("pressed", COLORS.text_main), - ("active", COLORS.text_main), + ("pressed", self.colors.text_main), + ("active", self.colors.text_main), ], ) self.style.configure( _STYLE_PRIMARY_BUTTON, - background=COLORS.action, - foreground=COLORS.text_main, - bordercolor=COLORS.action, + background=self.colors.action, + foreground=self.colors.action_text, + bordercolor=self.colors.action, + lightcolor=self.colors.action, + darkcolor=self.colors.action, borderwidth=1, font=FONTS.button, - padding=(26, 16), + padding=self._spacing(26, 16), ) self.style.map( _STYLE_PRIMARY_BUTTON, background=[ - ("disabled", COLORS.border), - ("pressed", COLORS.action_hover), - ("active", COLORS.action_hover), + ("disabled", self.colors.border), + ("pressed", self.colors.action_hover), + ("active", self.colors.action_hover), ], foreground=[ - ("disabled", COLORS.text_dim), - ("pressed", COLORS.text_main), - ("active", COLORS.text_main), + ("disabled", self.colors.text_dim), + ("pressed", self.colors.action_text), + ("active", self.colors.action_text), ], ) self.style.configure( _STYLE_DANGER_BUTTON, - background=COLORS.error, - foreground=COLORS.background, - bordercolor=COLORS.error, + background=self.colors.error, + foreground=self.colors.background, + bordercolor=self.colors.error, borderwidth=1, font=FONTS.button, - padding=(26, 16), + padding=self._spacing(26, 16), ) self.style.map( _STYLE_DANGER_BUTTON, background=[ - ("disabled", COLORS.border), + ("disabled", self.colors.border), ("pressed", "#E25D72"), ("active", "#E25D72"), ], - foreground=[("disabled", COLORS.text_dim)], + foreground=[("disabled", self.colors.text_dim)], + ) + for button_style, background in ( + ("Header.TButton", self.colors.header), + ("Setup.TButton", self.colors.surface), + ): + self.style.configure( + button_style, + background=background, + foreground=self.colors.action, + bordercolor=self.colors.action, + lightcolor=background, + darkcolor=background, + padding=self._spacing(18, 10), + ) + self.style.map( + button_style, + background=[("active", self.colors.secondary)], + foreground=[("disabled", self.colors.text_dim)], + ) + self.style.configure( + "TScrollbar", + arrowsize=self._px(16), + width=self._px(16), + background=self.colors.secondary, + troughcolor=self.colors.background, + bordercolor=self.colors.background, + arrowcolor=self.colors.text_dim, + lightcolor=self.colors.secondary, + darkcolor=self.colors.secondary, + ) + self.style.map( + "TScrollbar", + background=[ + ("disabled", self.colors.secondary), + ("active", self.colors.border), + ], + arrowcolor=[("disabled", self.colors.text_dim)], + ) + for control in (_STYLE_CARD_CHECKBUTTON, _STYLE_CARD_RADIOBUTTON): + self.style.configure( + control, + indicatorsize=self._px(18), + indicatormargin=self._spacing(0, 2, 10, 2), + upperbordercolor=self.colors.text_dim, + lowerbordercolor=self.colors.text_dim, + ) + self.style.map( + control, + indicatorbackground=[ + ( + "selected", + ( + self.colors.input + if control == _STYLE_CARD_RADIOBUTTON + else self.colors.action + ), + ), + ("!selected", self.colors.input), + ], + indicatorforeground=[ + ( + "selected", + ( + self.colors.action + if control == _STYLE_CARD_RADIOBUTTON + else self.colors.action_text + ), + ) + ], + indicatorcolor=[("selected", self.colors.action)], + ) + self.root.option_add("*TCombobox*Listbox.background", self.colors.input) + self.root.option_add("*TCombobox*Listbox.foreground", self.colors.text_main) + self.root.option_add("*TCombobox*Listbox.selectBackground", self.colors.action) + self.root.option_add( + "*TCombobox*Listbox.selectForeground", self.colors.action_text ) # Progress bar self.style.configure( "Horizontal.TProgressbar", thickness=12, - troughcolor=COLORS.input, - background=COLORS.success, - bordercolor=COLORS.border, + troughcolor=self.colors.input, + background=self.colors.success, + bordercolor=self.colors.border, ) # Specialized Labels self.style.configure( _STYLE_HEADER_LABEL, font=FONTS.header, - foreground=COLORS.text_main, - background=COLORS.background, + foreground=self.colors.text_main, + background=self.colors.background, ) self.style.configure( _STYLE_SUB_LABEL, font=FONTS.sub, - foreground=COLORS.text_dim, - background=COLORS.background, + foreground=self.colors.text_dim, + background=self.colors.background, ) self.style.configure( "ManifestTitle.TLabel", font=FONTS.card_title, - foreground=COLORS.text_main, - background=COLORS.surface, + foreground=self.colors.text_main, + background=self.colors.surface, ) # Semantic readiness states. Colour reinforces the label text; it is # never the only signal, per the DESIGN.md validation-state rule. for count_style, count_color in ( - (_STYLE_COUNT_SELECTED_LABEL, COLORS.text_main), - ("CountMuted.TLabel", COLORS.text_dim), - ("CountError.TLabel", COLORS.error), + (_STYLE_COUNT_SELECTED_LABEL, self.colors.text_main), + ("CountMuted.TLabel", self.colors.text_dim), + ("CountError.TLabel", self.colors.error), ): self.style.configure( count_style, font=FONTS.bold, foreground=count_color, - background=COLORS.surface, + background=self.colors.surface, ) # Status labels (success / error variants) self.style.configure( _STYLE_SUCCESS_LABEL, font=FONTS.sub, - foreground=COLORS.success, - background=COLORS.background, + foreground=self.colors.success, + background=self.colors.background, ) self.style.configure( _STYLE_ERROR_LABEL, font=FONTS.sub, - foreground=COLORS.error, - background=COLORS.background, + foreground=self.colors.error, + background=self.colors.background, ) # Checkbuttons self.style.configure( "TCheckbutton", - background=COLORS.background, - foreground=COLORS.text_main, + background=self.colors.background, + foreground=self.colors.text_main, font=FONTS.sub, ) self.style.map( "TCheckbutton", - background=[("active", COLORS.background)], - foreground=[("disabled", COLORS.text_dim)], + background=[("active", self.colors.background)], + foreground=[("disabled", self.colors.text_dim)], ) self.style.configure( "TRadiobutton", - background=COLORS.background, - foreground=COLORS.text_main, + background=self.colors.background, + foreground=self.colors.text_main, font=FONTS.sub, ) self.style.map( "TRadiobutton", - background=[("active", COLORS.background)], - foreground=[("disabled", COLORS.text_dim)], + background=[("active", self.colors.background)], + foreground=[("disabled", self.colors.text_dim)], ) self.style.configure( _STYLE_CARD_CHECKBUTTON, - background=COLORS.surface, - foreground=COLORS.text_main, + background=self.colors.surface, + foreground=self.colors.text_main, font=FONTS.bold, ) self.style.map( _STYLE_CARD_CHECKBUTTON, - background=[("active", COLORS.surface)], - foreground=[("disabled", COLORS.text_dim)], + background=[("active", self.colors.surface)], + foreground=[("disabled", self.colors.text_dim)], ) self.style.configure( _STYLE_CARD_RADIOBUTTON, - background=COLORS.surface, - foreground=COLORS.text_main, + background=self.colors.surface, + foreground=self.colors.text_main, font=FONTS.main, ) self.style.map( _STYLE_CARD_RADIOBUTTON, - background=[("active", COLORS.surface)], - foreground=[("disabled", COLORS.text_dim)], + background=[("active", self.colors.surface)], + foreground=[("disabled", self.colors.text_dim)], ) self.style.configure( "Card.TSeparator", - background=COLORS.border, - ) - - # OptionMenu (printer dropdown) - self.style.configure( - "TMenubutton", - background=COLORS.input, - foreground=COLORS.text_main, - borderwidth=0, - font=FONTS.main, - padding=(10, 6), - ) - self.style.map( - "TMenubutton", - background=[ - ("disabled", COLORS.border), - ("active", COLORS.secondary), - ], - foreground=[("disabled", COLORS.text_dim)], + background=self.colors.border, ) # Clam otherwise relies on subtle platform defaults for keyboard focus. @@ -606,14 +872,36 @@ def _configure_styles(self) -> None: ): self.style.configure( style_name, - focuscolor=COLORS.night_accent, + focuscolor=self.colors.night_accent, focusthickness=2, ) self.style.map( style_name, - bordercolor=[("focus", COLORS.night_accent)], - lightcolor=[("focus", COLORS.night_accent)], - darkcolor=[("focus", COLORS.night_accent)], + bordercolor=[("focus", self.colors.night_accent)], + lightcolor=[("focus", self.colors.night_accent)], + darkcolor=[("focus", self.colors.night_accent)], + ) + + # tkcalendar copies, rather than inherits, TCombobox at construction. + # Refresh that copy as well when only our palette changes within clam. + self.style.configure( + "DateEntry", **cast(dict[str, Any], self.style.configure("TCombobox")) + ) + self.style.map("DateEntry", **cast(dict[str, Any], self.style.map("TCombobox"))) + + def _style_theme_menu(self) -> None: + """Restyle the native choice menu, including already-created popups.""" + if self._theme_menu is not None: + self._theme_menu.configure( + background=self.colors.surface, + foreground=self.colors.text_main, + activebackground=self.colors.action, + activeforeground=self.colors.action_text, + selectcolor=self.colors.action, + font=FONTS.main, + borderwidth=1, + activeborderwidth=self._px(6), + relief="solid", ) def _apply_icon(self) -> None: @@ -626,7 +914,11 @@ def _apply_icon(self) -> None: ico_path = Path(base) / "icon.ico" png_path = Path(base) / "icon.png" if ico_path.exists(): - self.root.iconbitmap(str(ico_path)) + if sys.platform == "win32": + # The default also reaches owned windows and new Tk frames. + self.root.iconbitmap(default=str(ico_path)) + else: + self.root.iconbitmap(str(ico_path)) elif png_path.exists(): img = tk.PhotoImage(file=str(png_path)) self.root.iconphoto(True, img) @@ -642,8 +934,12 @@ def _center_window(self) -> None: w = self.root.winfo_width() h = self.root.winfo_height() left, top, right, bottom = _get_work_area(self.root) - x = left + max(0, (right - left - w - _WINDOW_FRAME_WIDTH_RESERVE) // 2) - y = top + max(0, (bottom - top - h - _WINDOW_FRAME_HEIGHT_RESERVE) // 2) + x = left + max( + 0, (right - left - w - self._px(_WINDOW_FRAME_WIDTH_RESERVE)) // 2 + ) + y = top + max( + 0, (bottom - top - h - self._px(_WINDOW_FRAME_HEIGHT_RESERVE)) // 2 + ) self.root.geometry(f"{w}x{h}+{x}+{y}") except Exception as e: logger.debug(f"Could not center window: {e}") @@ -676,16 +972,7 @@ def refresh_printers(self) -> None: menu = self.printer_dropdown["menu"] menu.delete(0, "end") for name in printers: - # tk._setit is an internal helper; keep usage isolated here. - set_it = getattr(tk, "_setit", None) - if callable(set_it): - menu.add_command(label=name, command=set_it(self.printer_var, name)) - else: - var = self.printer_var - if var is not None: - menu.add_command( - label=name, command=lambda v=name, vv=var: vv.set(v) - ) + menu.add_radiobutton(label=name, value=name, variable=self.printer_var) current = self.printer_var.get() if current and current in printers: @@ -720,11 +1007,13 @@ def _update_printer_status(self, printers: list[str]) -> None: def _create_widgets(self) -> None: """Create all UI widgets.""" + # Reserve the action area before the scrolling form takes remaining space. + self._create_footer(self.root) viewport = ttk.Frame(self.root) viewport.pack(fill="both", expand=True) self._main_canvas = tk.Canvas( viewport, - background=COLORS.background, + background=self.colors.background, borderwidth=0, highlightthickness=0, ) @@ -736,7 +1025,7 @@ def _create_widgets(self) -> None: self._main_canvas.configure(yscrollcommand=self._main_scrollbar.set) self._main_canvas.pack(side="left", fill="both", expand=True) - bg_canvas = ttk.Frame(self._main_canvas, padding="28") + bg_canvas = ttk.Frame(self._main_canvas) self._main_content = bg_canvas self._main_content_window = self._main_canvas.create_window( (0, 0), window=bg_canvas, anchor="nw" @@ -747,10 +1036,15 @@ def _create_widgets(self) -> None: self.root.bind(_FOCUS_IN_EVENT, self._ensure_focus_visible, add="+") self._create_header(bg_canvas) - self._create_setup_card(bg_canvas) - self._create_shift_selection_row(bg_canvas) - self._create_manifest_card(bg_canvas) - self._create_footer(bg_canvas) + workspace = ttk.Frame( + bg_canvas, + padding=(self._px(24), self._gap(18), self._px(24), self._gap(8)), + ) + workspace.pack(fill="both", expand=True) + self._create_setup_card(workspace) + self._create_run_heading(workspace) + self._create_shift_selection_row(workspace) + self._create_manifest_card(workspace) self.root.bind("", lambda _event: self._show_setup_dialog()) self.root.bind("", lambda _event: self.show_help()) self.refresh_manifest_preview() @@ -833,30 +1127,71 @@ def _ensure_widget_visible( logger.debug(f"Could not reveal focused control: {e}") def _create_header(self, parent: ttk.Frame) -> None: - """Create the header section.""" - header_row = ttk.Frame(parent) - header_row.pack(fill="x", pady=(0, 20)) - - title_row = ttk.Frame(header_row) - title_row.pack(fill="x") + """Give the workspace its identity, dark palette selection, and help.""" + header_row = ttk.Frame( + parent, style=_STYLE_HEADER_FRAME, padding=(self._px(28), self._gap(18)) + ) + header_row.pack(fill="x") + brand = ttk.Frame(header_row, style=_STYLE_HEADER_FRAME) + brand.pack(side="left") + ttk.Label(brand, text="ShiftPress", style="Brand.TLabel").pack(anchor="w") ttk.Label( - title_row, - text="Prepare print run", - style=_STYLE_HEADER_LABEL, - ).pack(side="left") + brand, + text="Schedules, ready for the next shift.", + style=_STYLE_HEADER_SUB_LABEL, + ).pack(anchor="w", pady=self._spacing(2, 0)) version = _get_version() if version: ttk.Label( - title_row, - text=f"ShiftPress · v{version}", - style=_STYLE_SUB_LABEL, - ).pack(side="right", anchor="s", pady=(0, 5)) + header_row, text=f"v{version}", style=_STYLE_HEADER_SUB_LABEL + ).pack(side="right", padx=self._spacing(24, 0)) + self._help_button = ttk.Button( + header_row, + text="How to use", + underline=0, + style="Header.TButton", + command=self.show_help, + ) + self._help_button.pack(side="right", padx=self._spacing(16, 0)) + self._theme_picker = ttk.Menubutton( + header_row, + textvariable=self.theme_var, + style="TMenubutton", + width=9, + direction="below", + takefocus=True, + ) + self._theme_menu = tk.Menu(self._theme_picker, tearoff=False) + for theme in ("Midnight", "Rose"): + self._theme_menu.add_radiobutton( + label=theme, + value=theme, + variable=self.theme_var, + command=lambda: self.set_theme(self.get_theme()), + ) + self._theme_picker.configure(menu=self._theme_menu) + self._style_theme_menu() + self._theme_picker.pack(side="right") + ttk.Label(header_row, text="Theme", style=_STYLE_HEADER_SUB_LABEL).pack( + side="right", padx=self._spacing(16, 10) + ) + def _create_run_heading(self, parent: ttk.Frame) -> None: + """Place the task title next to the action that restores its defaults.""" + row = ttk.Frame(parent) + row.pack(fill="x", pady=(self._gap(24), self._gap(14))) + self._reset_btn = ttk.Button( + row, text="Reset run", style=_STYLE_TERTIARY_BUTTON, command=self._reset_run + ) + self._reset_btn.pack(side="right", anchor="s") + ttk.Label(row, text="Prepare print run", style="Section.TLabel").pack( + anchor="w" + ) ttk.Label( - header_row, + row, text="Choose exactly which schedules go to print.", style=_STYLE_SUB_LABEL, - ).pack(anchor="w", pady=(6, 0)) + ).pack(anchor="w", pady=self._spacing(5, 0)) def _create_titled_card( self, @@ -865,50 +1200,69 @@ def _create_titled_card( style_prefix: str, padding: int, ) -> tuple[ttk.Frame, ttk.Frame]: - """Create a native card with a clean title and thin accent rule.""" - shell = ttk.Frame(parent) - title_row = ttk.Frame(shell) + """Create a native panel with a tinted, full-width title band.""" + shell = ttk.Frame(parent, style=f"{style_prefix}Card.TFrame", padding=1) + title_row = ttk.Frame( + shell, + style=f"{style_prefix}Header.TFrame", + padding=(self._px(18), self._gap(11)), + ) title_row.pack(fill="x") ttk.Label( title_row, text=title, style=f"{style_prefix}Title.TLabel", ).pack(side="left") - ttk.Separator( - title_row, - style=f"{style_prefix}Header.TSeparator", - ).pack(side="left", fill="x", expand=True, padx=(8, 0)) - card = ttk.Frame( shell, - style=f"{style_prefix}Card.TFrame", - padding=str(padding), + style=_STYLE_CARD_FRAME, + padding=(self._px(padding), self._gap(padding)), ) card.pack(fill="both", expand=True) return shell, card def _create_setup_card(self, parent: ttk.Frame) -> None: """Create a setup summary whose details open in a separate dialog.""" - shell, card = self._create_titled_card(parent, "Setup", "Setup", 18) - shell.pack(fill="x", pady=(0, 16)) - - summary_row = ttk.Frame(card, style=_STYLE_CARD_FRAME) - summary_row.pack(fill="x") - self.setup_summary_label = ttk.Label( - summary_row, - text="Template folders not configured\nChoose a printer", - style="Card.TLabel", - justify="left", - ) - self.setup_summary_label.pack(side="left", anchor="w") + card = ttk.Frame( + parent, + style="SetupCard.TFrame", + padding=(self._px(18), self._gap(12), self._px(18), self._gap(18)), + ) + card.pack(fill="x") + ttk.Label(card, text="Print setup", style="SetupTitle.TLabel").grid( + row=0, column=0, columnspan=3, sticky="w", pady=self._spacing(0, 12) + ) + for column, (key, title) in enumerate( + ( + ("night", "Night templates"), + ("day", "Day templates"), + ("printer", "Printer"), + ) + ): + card.grid_columnconfigure(column, weight=1, uniform="setup") + group = ttk.Frame(card, style=_STYLE_CARD_FRAME) + group.grid(row=1, column=column, sticky="nsew", padx=self._spacing(0, 18)) + ttk.Label(group, text=title, style=_STYLE_CARD_LABEL).pack( + anchor="w", pady=self._spacing(0, 6) + ) + value = ttk.Label( + group, + text=_NOT_CONFIGURED_LABEL, + style="SetupValue.TLabel", + wraplength=self._px(240), + justify="left", + ) + value.pack(anchor="w", fill="x") + self._setup_values[key] = value self._setup_toggle_btn = ttk.Button( - summary_row, + card, text="Setup…", + style="Setup.TButton", underline=0, command=self._show_setup_dialog, width=12, ) - self._setup_toggle_btn.pack(side="right", padx=(18, 0)) + self._setup_toggle_btn.grid(row=1, column=3, padx=self._spacing(12, 0)) self._create_setup_dialog() self.refresh_setup_summary() @@ -919,16 +1273,17 @@ def _create_setup_dialog(self) -> None: self._setup_dialog = dialog dialog.withdraw() dialog.title("ShiftPress Setup") - dialog.configure(bg=COLORS.background) + dialog.transient(self.root) + dialog.configure(bg=self.colors.background) dialog.resizable(True, True) dialog.protocol("WM_DELETE_WINDOW", self._cancel_setup_dialog) - dialog.bind("", lambda _event: self._cancel_setup_dialog()) + dialog.bind(_ESCAPE_EVENT, lambda _event: self._cancel_setup_dialog()) viewport = ttk.Frame(dialog) viewport.pack(fill="both", expand=True) self._setup_canvas = tk.Canvas( viewport, - background=COLORS.background, + background=self.colors.background, borderwidth=0, highlightthickness=0, ) @@ -939,7 +1294,7 @@ def _create_setup_dialog(self) -> None: ) self._setup_canvas.configure(yscrollcommand=self._setup_scrollbar.set) self._setup_canvas.pack(side="left", fill="both", expand=True) - canvas = ttk.Frame(self._setup_canvas, padding="24") + canvas = ttk.Frame(self._setup_canvas, padding=self._px(24)) self._setup_content = canvas self._setup_content_window = self._setup_canvas.create_window( (0, 0), window=canvas, anchor="nw" @@ -953,12 +1308,12 @@ def _create_setup_dialog(self) -> None: canvas, text="Choose the template folders and printer used for print runs.", style=_STYLE_SUB_LABEL, - ).pack(anchor="w", pady=(4, 18)) + ).pack(anchor="w", pady=self._spacing(4, 18)) self._setup_details = ttk.Frame( canvas, style="DialogCard.TFrame", - padding="18", + padding=self._px(18), ) self._setup_details.pack(fill="both", expand=True) @@ -971,13 +1326,13 @@ def _create_setup_dialog(self) -> None: _setup_placeholder(self.night_entry, _PATH_PLACEHOLDER) self._create_printer_row(self._setup_details) action_row = ttk.Frame(canvas) - action_row.pack(anchor="e", pady=(18, 0)) + action_row.pack(anchor="e", pady=self._spacing(18, 0)) ttk.Button( action_row, text="Cancel", command=self._cancel_setup_dialog, width=14, - ).pack(side="left", padx=(0, 10)) + ).pack(side="left", padx=self._spacing(0, 10)) ttk.Button( action_row, text="Apply", @@ -1045,7 +1400,7 @@ def _ensure_setup_focus_visible(self, event: Any) -> None: def _show_setup_dialog(self) -> None: """Show setup without expanding or displacing the print work surface.""" - if self._setup_dialog is None: + if not self._inputs_enabled or self._setup_dialog is None: return dialog = self._setup_dialog self._setup_snapshot = ( @@ -1055,7 +1410,6 @@ def _show_setup_dialog(self) -> None: ) dialog.deiconify() try: - dialog.transient(self.root) dialog.update_idletasks() if self._setup_content is None: return @@ -1082,6 +1436,9 @@ def _show_setup_dialog(self) -> None: except Exception as e: logger.debug(f"Could not position setup dialog: {e}") dialog.lift() + # Ownership and the first lift can create a new Windows frame. Apply + # DWM colors only after that frame exists, including on the first open. + self._style_titlebar(dialog) dialog.focus_force() if self.night_entry is not None: self.night_entry.focus_set() @@ -1115,8 +1472,6 @@ def _hide_setup_dialog(self) -> None: def refresh_setup_summary(self) -> None: """Identify configured template sources without exposing long paths.""" - if self.setup_summary_label is None: - return day_folder = self.get_day_folder() night_folder = self.get_night_folder() printer = self.get_printer_name() @@ -1125,22 +1480,22 @@ def refresh_setup_summary(self) -> None: ) day_status = self._folder_tail(day_folder) night_status = self._folder_tail(night_folder) - self.setup_summary_label.config( - text=( - f"Night templates: {night_status}\n" - f"Day templates: {day_status}\n" - f"Printer: {printer_status}" - ) - ) + for key, value in ( + ("night", night_status), + ("day", day_status), + ("printer", printer_status), + ): + if key in self._setup_values: + self._setup_values[key].config(text=value) @staticmethod def _folder_tail(folder: str) -> str: """Return a compact, recognizable two-part folder identity.""" if not isinstance(folder, str): - return "Not configured" + return _NOT_CONFIGURED_LABEL value = folder.strip().rstrip("/\\") if not value or value == _PATH_PLACEHOLDER: - return "Not configured" + return _NOT_CONFIGURED_LABEL parts = [part for part in re.split(r"[/\\]+", value) if part] if not parts: return value @@ -1161,7 +1516,7 @@ def _folder_tail(folder: str) -> str: def _create_shift_selection_row(self, parent: ttk.Frame) -> None: """Create equal-width Night and Day selection panels.""" row = ttk.Frame(parent) - row.pack(fill="x", pady=(0, 16)) + row.pack(fill="x", pady=self._spacing(0, 16)) row.grid_columnconfigure(0, weight=1, uniform="shift") row.grid_columnconfigure(1, weight=1, uniform="shift") @@ -1174,7 +1529,7 @@ def _create_shift_selection_row(self, parent: ttk.Frame) -> None: row, text=self._dependency_error, style=_STYLE_ERROR_LABEL, - wraplength=760, + wraplength=self._px(760), justify="left", ).grid(row=0, column=0, columnspan=2, sticky="ew") logger.error("tkcalendar is not installed; date pickers unavailable") @@ -1203,26 +1558,27 @@ def _calendar_kwargs(self, select_color: str) -> dict[str, Any]: Calendar option mapping to pass inline to ``DateEntry``. """ return { - "background": COLORS.background, - "foreground": COLORS.text_main, - "bordercolor": COLORS.border, - "headersbackground": COLORS.background, - "headersforeground": COLORS.text_dim, + "font": FONTS.main, + "background": self.colors.background, + "foreground": self.colors.text_main, + "bordercolor": self.colors.border, + "headersbackground": self.colors.background, + "headersforeground": self.colors.text_dim, # Dark ink on the accent: near-white on amber measures 1.69:1. "selectbackground": select_color, - "selectforeground": COLORS.background, - "normalbackground": COLORS.surface, - "normalforeground": COLORS.text_main, - "weekendbackground": COLORS.surface, - "weekendforeground": COLORS.text_dim, - "othermonthbackground": COLORS.background, - "othermonthforeground": COLORS.text_dim, - "othermonthwebackground": COLORS.background, - "othermonthweforeground": COLORS.text_dim, - "disabledbackground": COLORS.surface, - "disabledforeground": COLORS.border, - "disableddaybackground": COLORS.surface, - "disableddayforeground": COLORS.border, + "selectforeground": self.colors.background, + "normalbackground": self.colors.surface, + "normalforeground": self.colors.text_main, + "weekendbackground": self.colors.surface, + "weekendforeground": self.colors.text_dim, + "othermonthbackground": self.colors.background, + "othermonthforeground": self.colors.text_dim, + "othermonthwebackground": self.colors.background, + "othermonthweforeground": self.colors.text_dim, + "disabledbackground": self.colors.surface, + "disabledforeground": self.colors.border, + "disableddaybackground": self.colors.surface, + "disableddayforeground": self.colors.border, # ISO week numbers are noise for a print-run date choice. "showweeknumbers": False, "firstweekday": "sunday", @@ -1238,11 +1594,17 @@ def _create_shift_panel( """Create one independent native shift selection panel.""" date_entry_cls = cast(Any, DateEntry) label = shift_type.title() - accent = COLORS.night_accent if shift_type == "night" else COLORS.day_accent + accent = ( + self.colors.night_accent + if shift_type == "night" + else self.colors.day_accent + ) calendar_kw = self._calendar_kwargs(accent) horizontal_padding = (0, 8) if column == 0 else (8, 0) - shell, card = self._create_titled_card(parent, label, label, 22) - shell.grid(row=0, column=column, sticky="nsew", padx=horizontal_padding) + shell, card = self._create_titled_card(parent, f"{label} schedule", label, 18) + shell.grid( + row=0, column=column, sticky="nsew", padx=self._spacing(*horizontal_padding) + ) enabled_var = tk.BooleanVar(value=True) mode_var = tk.StringVar(value="single") @@ -1257,10 +1619,10 @@ def sync_panel(selected: ShiftType = shift_type) -> None: command=sync_panel, style=_STYLE_CARD_CHECKBUTTON, ) - include_check.pack(anchor="w", pady=(0, 18)) + include_check.pack(anchor="w", pady=(0, self._gap(18))) mode_row = ttk.Frame(card, style=_STYLE_CARD_FRAME) - mode_row.pack(fill="x", pady=(0, 18)) + mode_row.pack(fill="x", pady=(0, self._gap(18))) single_radio = ttk.Radiobutton( mode_row, text="Single date", @@ -1269,7 +1631,7 @@ def sync_panel(selected: ShiftType = shift_type) -> None: command=sync_panel, style=_STYLE_CARD_RADIOBUTTON, ) - single_radio.pack(anchor="w", pady=(0, 10)) + single_radio.pack(side="left", padx=self._spacing(0, 24)) range_radio = ttk.Radiobutton( mode_row, text="Date range", @@ -1278,7 +1640,7 @@ def sync_panel(selected: ShiftType = shift_type) -> None: command=sync_panel, style=_STYLE_CARD_RADIOBUTTON, ) - range_radio.pack(anchor="w") + range_radio.pack(side="left") date_stack = ttk.Frame(card, style=_STYLE_CARD_FRAME) date_stack.pack(fill="x") @@ -1289,7 +1651,7 @@ def sync_panel(selected: ShiftType = shift_type) -> None: single_wrap = ttk.Frame(date_stack, style=_STYLE_CARD_FRAME) single_wrap.grid(row=0, column=0, sticky="ew") ttk.Label(single_wrap, text="Date", style=_STYLE_CARD_SUB_LABEL).pack( - anchor="w", pady=(0, 6) + anchor="w", pady=self._spacing(0, 6) ) single_picker = self._create_date_entry( date_entry_cls, @@ -1305,10 +1667,10 @@ def sync_panel(selected: ShiftType = shift_type) -> None: range_wrap.grid_columnconfigure(1, weight=1) range_start_wrap = ttk.Frame(range_wrap, style=_STYLE_CARD_FRAME) - range_start_wrap.grid(row=0, column=0, sticky="ew", padx=(0, 6)) + range_start_wrap.grid(row=0, column=0, sticky="ew", padx=self._spacing(0, 6)) ttk.Label( range_start_wrap, text="Start date", style=_STYLE_CARD_SUB_LABEL - ).pack(anchor="w", pady=(0, 6)) + ).pack(anchor="w", pady=self._spacing(0, 6)) range_start_picker = self._create_date_entry( date_entry_cls, range_start_wrap, @@ -1318,9 +1680,9 @@ def sync_panel(selected: ShiftType = shift_type) -> None: range_start_picker.set_date(default_date) range_end_wrap = ttk.Frame(range_wrap, style=_STYLE_CARD_FRAME) - range_end_wrap.grid(row=0, column=1, sticky="ew", padx=(6, 0)) + range_end_wrap.grid(row=0, column=1, sticky="ew", padx=self._spacing(6, 0)) ttk.Label(range_end_wrap, text="End date", style=_STYLE_CARD_SUB_LABEL).pack( - anchor="w", pady=(0, 6) + anchor="w", pady=self._spacing(0, 6) ) range_end_picker = self._create_date_entry( date_entry_cls, @@ -1330,13 +1692,12 @@ def sync_panel(selected: ShiftType = shift_type) -> None: range_end_picker.pack(fill="x") range_end_picker.set_date(default_date) - ttk.Separator(card, style="Card.TSeparator").pack(fill="x", pady=(20, 14)) count_label = ttk.Label( card, text="Selected · 1 document", style=_STYLE_COUNT_SELECTED_LABEL, ) - count_label.pack(anchor="w") + count_label.pack(anchor="w", pady=(self._gap(18), 0)) panel = _ShiftPanelWidgets( enabled_var=enabled_var, @@ -1384,10 +1745,10 @@ def open_calendar(_event: Any = None) -> str: logger.debug(f"Could not open calendar: {e}") return "break" - # Mouse anywhere in the field, plus the keyboard affordances a - # readonly field would otherwise lose. + # Replace tkcalendar's arrow-click handler: adding ours would toggle + # the popup twice when the arrow itself is clicked. for sequence in ("", "", "", ""): - picker.bind(sequence, open_calendar, add="+") + picker.bind(sequence, open_calendar) def _on_shift_date_selected(self, shift_type: ShiftType) -> None: """Refresh visible intent after a shift date changes.""" @@ -1396,6 +1757,8 @@ def _on_shift_date_selected(self, shift_type: ShiftType) -> None: def _reset_run(self) -> None: """Restore the common Night-today and Day-tomorrow print scope.""" + if not self._inputs_enabled: + return defaults: dict[ShiftType, date] = { "night": self._today, "day": self._today + timedelta(days=1), @@ -1457,24 +1820,40 @@ def _sync_shift_panel_state( def _create_manifest_card(self, parent: ttk.Frame) -> None: """Create the exact preflight-neutral print manifest summary.""" - card = ttk.Frame(parent, style="Manifest.TFrame", padding="18") + card = ttk.Frame( + parent, style="Manifest.TFrame", padding=(self._px(18), self._gap(18)) + ) self._manifest_card = card - card.pack(fill="x", pady=(0, 16)) + card.pack(fill="x", pady=self._spacing(0, 16)) + card.columnconfigure(0, weight=2) + card.columnconfigure(1, weight=1) self.manifest_title_label = ttk.Label( card, text="Print scope: No schedules selected", style="ManifestTitle.TLabel", ) - self.manifest_title_label.pack(anchor="w", pady=(0, 10)) + self.manifest_title_label.grid( + row=0, column=0, sticky="w", pady=self._spacing(0, 8) + ) self.manifest_label = ttk.Label( card, - text="Printer: Choose a printer", + text="Select schedules to see the print scope", style=_STYLE_CARD_SUB_LABEL, justify="left", anchor="w", - wraplength=760, + wraplength=self._px(620), + ) + self.manifest_label.grid(row=1, column=0, sticky="ew") + self._manifest_printer_label = ttk.Label( + card, + text="Printer\nChoose a printer", + style=_STYLE_CARD_LABEL, + justify="left", + wraplength=self._px(280), + ) + self._manifest_printer_label.grid( + row=0, column=1, rowspan=2, sticky="e", padx=self._spacing(24, 0) ) - self.manifest_label.pack(fill="x") def _create_date_entry( self, @@ -1540,25 +1919,28 @@ def _apply_content_sizing(self) -> None: if self._main_content is None: return req_w = self._main_content.winfo_reqwidth() - req_h = self._main_content.winfo_reqheight() + footer_height = ( + self._footer_frame.winfo_reqheight() if self._footer_frame else 0 + ) + req_h = self._main_content.winfo_reqheight() + footer_height left, top, right, bottom = _get_work_area(self.root) work_width = right - left work_height = bottom - top target_w = min( - max(WINDOW_WIDTH, req_w), + max(self._px(WINDOW_WIDTH), req_w), max( AUTO_RESIZE_MIN_WIDTH, - work_width - _WINDOW_FRAME_WIDTH_RESERVE, + work_width - self._px(_WINDOW_FRAME_WIDTH_RESERVE), ), ) usable_h = max( AUTO_RESIZE_MIN_HEIGHT, - work_height - _WINDOW_FRAME_HEIGHT_RESERVE, + work_height - self._px(_WINDOW_FRAME_HEIGHT_RESERVE), ) target_h = min(max(AUTO_RESIZE_MIN_HEIGHT, req_h), usable_h) - self.root.minsize(target_w, AUTO_RESIZE_MIN_HEIGHT) + self.root.minsize(min(target_w, self._px(900)), AUTO_RESIZE_MIN_HEIGHT) self.root.geometry(f"{target_w}x{target_h}") if req_h > target_h: self._show_main_scrollbar() @@ -1570,9 +1952,9 @@ def _apply_content_sizing(self) -> None: def _create_printer_row(self, parent: ttk.Frame | ttk.LabelFrame) -> None: """Create the printer selection row.""" output_row = ttk.Frame(parent, style=_STYLE_CARD_FRAME) - output_row.pack(fill="x", pady=(8, 0)) + output_row.pack(fill="x", pady=self._spacing(8, 0)) ttk.Label(output_row, text="Printer", style=_STYLE_CARD_SUB_LABEL).pack( - anchor="w", pady=(0, 8) + anchor="w", pady=self._spacing(0, 8) ) if win32print is None: @@ -1593,21 +1975,11 @@ def _create_printer_row(self, parent: ttk.Frame | ttk.LabelFrame) -> None: self.printer_dropdown = ttk.OptionMenu( printer_row, self.printer_var, DEFAULT_PRINTER_LABEL, *all_printers ) - self.printer_dropdown.pack(side="left", fill="x", expand=True, padx=(0, 10)) + self.printer_dropdown.pack( + side="left", fill="x", expand=True, padx=self._spacing(0, 10) + ) - # Style the dropdown popup menu to match the dark theme. - try: - menu = self.printer_dropdown["menu"] - menu.configure( - bg=COLORS.surface, - fg=COLORS.text_main, - activebackground=COLORS.action, - activeforeground=COLORS.text_main, - borderwidth=1, - relief="flat", - ) - except Exception as e: - logger.debug(f"Could not style printer dropdown menu: {e}") + self._style_printer_menu() self._refresh_btn = ttk.Button( printer_row, @@ -1622,31 +1994,50 @@ def _create_printer_row(self, parent: ttk.Frame | ttk.LabelFrame) -> None: output_row, text="", style=_STYLE_CARD_SUB_LABEL, - wraplength=640, + wraplength=self._px(640), justify="left", ) - self._printer_status_label.pack(anchor="w", pady=(6, 0)) + self._printer_status_label.pack(anchor="w", pady=self._spacing(6, 0)) self._update_printer_status(all_printers) + def _style_printer_menu(self) -> None: + """Keep the native printer popup in the selected dark palette.""" + if self.printer_dropdown is None: + return + try: + self.printer_dropdown["menu"].configure( + bg=self.colors.surface, + fg=self.colors.text_main, + activebackground=self.colors.action, + activeforeground=self.colors.action_text, + selectcolor=self.colors.action, + font=FONTS.main, + activeborderwidth=self._px(6), + borderwidth=1, + relief="solid", + ) + except tk.TclError as error: + logger.debug("Could not style printer dropdown menu: %s", error) + def _on_printer_changed(self, *_args: object) -> None: """Refresh setup and manifest copy after the printer changes.""" self.refresh_setup_summary() self.refresh_manifest_preview() - def _create_footer(self, parent: ttk.Frame) -> None: + def _create_footer(self, parent: tk.Misc) -> None: """Create the action footer (status, progress, button).""" - footer = ttk.Frame(parent) + footer = ttk.Frame(parent, padding=self._spacing(24, 12, 24, 16)) self._footer_frame = footer - footer.pack(fill="x") + footer.pack(side="bottom", fill="x") footer.grid_columnconfigure(0, weight=1) status_wrap = ttk.Frame(footer) - status_wrap.grid(row=0, column=0, sticky="ew", padx=(0, 24)) + status_wrap.grid(row=0, column=0, sticky="ew", padx=self._spacing(0, 24)) self.status_label = ttk.Label( status_wrap, text="Complete Setup to prepare a print scope", style=_STYLE_SUB_LABEL, - wraplength=620, + wraplength=self._px(620), justify="left", ) self.status_label.pack(side="left") @@ -1660,28 +2051,15 @@ def _create_footer(self, parent: ttk.Frame) -> None: ) self._logs_btn = open_logs_btn - reset_btn = ttk.Button( - status_wrap, - text="Reset run", - style=_STYLE_TERTIARY_BUTTON, - command=self._reset_run, - cursor="hand2", - ) - reset_btn.pack(side="left", padx=(12, 0)) - - help_btn = ttk.Button( - status_wrap, - text="How to use", - underline=0, - style=_STYLE_TERTIARY_BUTTON, - command=self.show_help, - cursor="hand2", - ) - help_btn.pack(side="left", padx=(12, 0)) - progress_row = ttk.Frame(footer) self._progress_row = progress_row - progress_row.grid(row=1, column=0, sticky="ew", padx=(0, 24), pady=(10, 0)) + progress_row.grid( + row=1, + column=0, + sticky="ew", + padx=self._spacing(0, 24), + pady=self._spacing(10, 0), + ) progress_row.grid_remove() self.progress_var = tk.DoubleVar() @@ -1691,7 +2069,9 @@ def _create_footer(self, parent: ttk.Frame) -> None: maximum=PROGRESS_MAX, style="Horizontal.TProgressbar", ) - self.progress.pack(side="left", fill="x", expand=True, padx=(0, 10)) + self.progress.pack( + side="left", fill="x", expand=True, padx=self._spacing(0, 10) + ) self._progress_pct = ttk.Label( progress_row, @@ -1707,7 +2087,7 @@ def _create_footer(self, parent: ttk.Frame) -> None: text=_PRINT_BUTTON_LABEL, underline=0, style=_STYLE_PRIMARY_BUTTON, - width=20, + width=26, cursor="hand2", ) self.print_btn.grid(row=0, column=1, rowspan=2, sticky="nsew") @@ -1727,9 +2107,9 @@ def _create_path_row( The entry widget """ wrap = ttk.Frame(parent, style=_STYLE_CARD_FRAME) - wrap.pack(fill="x", pady=8) + wrap.pack(fill="x", pady=self._px(8)) ttk.Label(wrap, text=label, style=_STYLE_CARD_SUB_LABEL).pack( - anchor="w", pady=(0, 6) + anchor="w", pady=self._spacing(0, 6) ) row = ttk.Frame(wrap, style=_STYLE_CARD_FRAME) @@ -1737,7 +2117,7 @@ def _create_path_row( entry = ttk.Entry(row) entry.insert(0, default_val) - entry.pack(side="left", fill="x", expand=True, padx=(0, 10)) + entry.pack(side="left", fill="x", expand=True, padx=self._spacing(0, 10)) browse_button = ttk.Button( row, @@ -1786,10 +2166,10 @@ def _set_folder_entry(self, entry: Optional[ttk.Entry], path: str) -> None: value = (path or "").strip() entry.delete(0, tk.END) if value: - entry.config(foreground=COLORS.text_main) + entry.config(foreground=self.colors.text_main) entry.insert(0, value) else: - entry.config(foreground=COLORS.text_dim) + entry.config(foreground=self.colors.text_dim) entry.insert(0, _PATH_PLACEHOLDER) def set_day_folder(self, path: str) -> None: @@ -2025,7 +2405,8 @@ def refresh_manifest_preview(self, update_status: bool = True) -> None: if printer and printer != DEFAULT_PRINTER_LABEL else "Choose a printer" ) - lines.append(f"Printer: {printer_label}") + if self._manifest_printer_label is not None: + self._manifest_printer_label.config(text=f"Printer\n{printer_label}") blocker = _manifest_blocker(selections, invalid, manifest, printer_label) if blocker: lines.append(f"Cannot print: {blocker}") @@ -2069,7 +2450,7 @@ def set_start_command( self.print_btn.bind("", lambda _event: command()) self.root.bind("", lambda _event: command()) if cancel_command is not None: - self.root.bind("", lambda _event: cancel_command()) + self.root.bind(_ESCAPE_EVENT, lambda _event: cancel_command()) def set_inputs_enabled(self, enabled: bool) -> None: """Enable or disable all input widgets during processing. @@ -2086,6 +2467,7 @@ def set_inputs_enabled(self, enabled: bool) -> None: self._set_widget_state(self.printer_dropdown, state, "printer dropdown") self._set_widget_state(self._refresh_btn, state, "refresh button") self._set_widget_state(self._setup_toggle_btn, state, "setup toggle") + self._set_widget_state(self._reset_btn, state, "reset run") for shift_type, panel in self._shift_panels.items(): self._set_shift_panel_enabled(shift_type, panel, state) @@ -2157,7 +2539,7 @@ def _set_logs_button_visibility(self, status_style: str) -> None: if self._logs_btn is None: return if status_style == _STYLE_ERROR_LABEL: - self._logs_btn.pack(side="left", padx=(12, 0)) + self._logs_btn.pack(side="left", padx=self._spacing(12, 0)) return self._logs_btn.pack_forget() @@ -2196,21 +2578,143 @@ def show_info(self, title: str, message: str) -> None: messagebox.showinfo(title, message) def show_help(self) -> None: - """Explain the print-run workflow in the operator's own terms.""" - self.show_info( - "How to use ShiftPress", - "1. In Setup, choose the Night and Day template folders and a printer.\n\n" - "Select Apply to keep Setup changes, or Cancel to restore the previous " - "folders and printer.\n\n" - "2. Include the Night schedule, Day schedule, or both. Choose a single " - "date or date range for each included shift.\n\n" - "3. Review Print scope, then select Print. ShiftPress runs preflight checks " - "before opening Word and stops if a required template or printer is " - "unavailable.\n\n" - "Reset run restores the common Night-today and Day-tomorrow selection.\n\n" - "Keyboard: Alt+S opens Setup, Alt+P prints, Alt+H opens this help, and " - "Escape stops after the current document.", - ) + """Open a themed, non-modal reference without losing the current run.""" + if self._help_dialog is None: + self._create_help_dialog() + dialog = self._help_dialog + if dialog is None: + return + dialog.deiconify() + self._style_help_dialog() + left, top, right, bottom = _get_work_area(dialog) + width = min(self._px(640), right - left - self._px(32)) + available_height = bottom - top - self._px(64) + height = min(self._px(610), available_height) + # Lay out at the chosen width before measuring wrapped, mixed-font text. + # A fixed height can clip just a few pixels and show a needless scrollbar. + dialog.geometry(f"{width}x{height}") + dialog.lift() + # Windows delivers child Configure events after the native frame maps; + # idle tasks alone leave first-open or previous-open text dimensions. + dialog.update() + if self._help_text is not None: + text = self._help_text + text_height = cast(int, text.count("1.0", "end", "update", "ypixels")) + text_height += 2 * int(text.cget("pady")) + chrome_height = dialog.winfo_height() - text.winfo_height() + height = min(text_height + chrome_height + self._px(8), available_height) + x = max( + left, + min(self.root.winfo_rootx() + self._px(48), right - width - self._px(16)), + ) + y = max( + top, + min(self.root.winfo_rooty() + self._px(24), bottom - height - self._px(48)), + ) + dialog.geometry(f"{width}x{height}+{x}+{y}") + dialog.minsize(min(width, self._px(420)), min(height, self._px(300))) + dialog.lift() + dialog.focus_force() + if self._help_text is not None: + self._help_text.focus_set() + + def _create_help_dialog(self) -> None: + """Build readable help with native scrolling and an always-visible Close.""" + dialog = tk.Toplevel(self.root) + self._help_dialog = dialog + dialog.withdraw() + dialog.title("How to use ShiftPress") + dialog.transient(self.root) + dialog.protocol("WM_DELETE_WINDOW", self._hide_help_dialog) + dialog.bind(_ESCAPE_EVENT, lambda _event: self._hide_help_dialog()) + actions = ttk.Frame(dialog, padding=self._spacing(24, 12)) + actions.pack(side="bottom", fill="x") + ttk.Button( + actions, text="Close", command=self._hide_help_dialog, width=12 + ).pack(side="right") + content = ttk.Frame(dialog, padding=self._spacing(24, 20, 12, 0)) + content.pack(fill="both", expand=True) + text = tk.Text( + content, + wrap="word", + font=FONTS.main, + borderwidth=0, + highlightthickness=0, + padx=self._px(4), + pady=self._px(4), + spacing3=self._px(12), + cursor="arrow", + takefocus=True, + ) + self._help_text = text + scrollbar = ttk.Scrollbar(content, orient="vertical", command=text.yview) + + def sync_scrollbar(first: str | float, last: str | float) -> None: + first_value, last_value = float(first), float(last) + scrollbar.set(first_value, last_value) + if first_value <= 0 and last_value >= 1: + scrollbar.pack_forget() + else: + scrollbar.pack( + side="right", fill="y", padx=self._spacing(12, 0), before=text + ) + + text.configure(yscrollcommand=sync_scrollbar) + text.pack(fill="both", expand=True) + text.tag_configure("title", font=FONTS.section, spacing3=self._px(20)) + text.tag_configure( + "heading", font=FONTS.card_title, spacing1=self._px(8), spacing3=self._px(6) + ) + text.insert("end", "How to use ShiftPress\n", "title") + for heading, body in ( + ( + "1. Set up your sources", + "In Setup, choose the Night and Day template folders and a printer.\n" + "Select Apply to keep Setup changes, or Cancel to restore the previous folders and printer.", + ), + ( + "2. Choose your schedules", + "Include the Night schedule, Day schedule, or both. Choose a single date or date range for each included shift.", + ), + ( + "3. Review and print", + "Review Print scope, then select Print. ShiftPress runs preflight checks before opening Word and stops if a required template or printer is unavailable.", + ), + ( + "Start a fresh run", + "Reset run restores the common Night-today and Day-tomorrow selection.", + ), + ( + "Keyboard shortcuts", + "Alt+S opens Setup · Alt+P prints · Alt+H opens help.\n" + "Escape closes this help. In the main window, Escape stops after the current document.", + ), + ): + text.insert("end", heading + "\n", "heading") + text.insert("end", body + "\n") + text.configure(state="disabled") + + def _style_help_dialog(self) -> None: + """Keep open help synchronized when the main window changes palette.""" + if self._help_dialog is None or self._help_text is None: + return + self._help_dialog.configure(bg=self.colors.background) + self._help_text.configure( + background=self.colors.background, + foreground=self.colors.text_main, + selectbackground=self.colors.action, + selectforeground=self.colors.action_text, + inactiveselectbackground=self.colors.secondary, + ) + self._help_text.tag_configure("heading", foreground=self.colors.action) + self._style_titlebar(self._help_dialog) + + def _hide_help_dialog(self) -> None: + """Return to the help action without mutating print state.""" + if self._help_dialog is not None: + self._help_dialog.withdraw() + if self._help_button is not None: + self._help_button.focus_set() def ask_yes_no(self, title: str, message: str) -> bool: """Ask the user a yes/no question. diff --git a/src/word_processor.py b/src/word_processor.py index 99d9242..3414222 100644 --- a/src/word_processor.py +++ b/src/word_processor.py @@ -128,7 +128,8 @@ def shutdown(self) -> None: """Shutdown the Word application instance.""" if self.word_app: try: - self.word_app.Quit() + # Discard edited copies even if an individual Close failed. + self.word_app.Quit(CLOSE_NO_SAVE) logger.info("Word application shut down") except Exception as e: logger.warning(f"Error shutting down Word: {e}") @@ -455,10 +456,8 @@ def print_document( # Background=False ensures synchronous printing self.safe_com_call(doc.PrintOut, False) - # Close document - self.safe_com_call(doc.Close, CLOSE_NO_SAVE) - doc = None - + # Submission succeeded. A later cleanup failure must not label this + # document as unprinted and encourage a duplicate retry. logger.info(f"Successfully printed: {template_name}") return True, None @@ -630,27 +629,25 @@ def _normalize_spaces_in_doc(self, doc: Any) -> None: ("^u8195", " ", "em space"), ] - for find_code, replace_with, desc in normalizations: - try: - for story in self._iter_story_ranges(doc): - f = story.Find - f.ClearFormatting() - f.Replacement.ClearFormatting() - f.Execute( - find_code, - False, # MatchCase - False, # MatchWholeWord - False, # MatchWildcards (must be False for ^codes) - False, # MatchSoundsLike - False, # MatchAllWordForms - True, # Forward - WD_FIND_CONTINUE, # Wrap - False, # Format - replace_with, - WD_REPLACE_ALL, # Replace - ) - except Exception as e: - logger.debug(f"{desc} normalization: {e}") + for find_code, replace_with, _desc in normalizations: + for story in self._iter_story_ranges(doc): + f = story.Find + self.safe_com_call(f.ClearFormatting) + self.safe_com_call(f.Replacement.ClearFormatting) + self.safe_com_call( + f.Execute, + find_code, + False, # MatchCase + False, # MatchWholeWord + False, # MatchWildcards (must be False for ^codes) + False, # MatchSoundsLike + False, # MatchAllWordForms + True, # Forward + WD_FIND_CONTINUE, # Wrap + False, # Format + replace_with, + WD_REPLACE_ALL, # Replace + ) def _execute_replace( self, @@ -670,12 +667,9 @@ def _execute_replace( True if at least one replacement was made """ any_replaced = False - try: - for story in self._iter_story_ranges(doc): - if self._run_find_replace(story, find_text, replace_text): - any_replaced = True - except Exception as e: - logger.warning(f"Error during find/replace: {e}") + for story in self._iter_story_ranges(doc): + if self._run_find_replace(story, find_text, replace_text): + any_replaced = True return any_replaced def _iter_story_ranges(self, doc: Any) -> Iterator[Any]: @@ -688,15 +682,13 @@ def _iter_story_ranges(self, doc: Any) -> Iterator[Any]: Word Range objects from the document's StoryRanges collection. """ - try: - for story in doc.StoryRanges: - # Include this story and its linked NextStoryRange chain - cur = story - while cur: - yield cur - cur = getattr(cur, "NextStoryRange", None) - except Exception as e: - logger.warning(f"Error iterating story ranges: {e}") + # A traversal failure is not the end of the document. Propagate it so + # a body match cannot mask an unprocessed header/footer or linked story. + for story in self.safe_com_call(getattr, doc, "StoryRanges"): + cur = story + while cur: + yield cur + cur = self.safe_com_call(getattr, cur, "NextStoryRange") def _run_find_replace( self, range_obj: Any, find_text: str, replace_text: str @@ -712,32 +704,28 @@ def _run_find_replace( Returns: True if the pattern was found and replaced """ - try: - f = range_obj.Find - f.ClearFormatting() - f.Replacement.ClearFormatting() - # Execute: FindText, MatchCase, MatchWholeWord, MatchWildcards, - # MatchSoundsLike, MatchAllWordForms, Forward, Wrap, Format, - # ReplaceWith, Replace - result = f.Execute( - find_text, # FindText - False, # MatchCase - False, # MatchWholeWord - True, # MatchWildcards - False, # MatchSoundsLike - False, # MatchAllWordForms - True, # Forward - WD_FIND_CONTINUE, # Wrap - False, # Format - replace_text, # ReplaceWith - WD_REPLACE_ALL, # Replace - ) - if result: - logger.debug(f"Find/replace matched: '{find_text}' -> '{replace_text}'") - return bool(result) - except Exception as e: - logger.warning(f"Error in find/replace operation: {e}") - return False + f = range_obj.Find + self.safe_com_call(f.ClearFormatting) + self.safe_com_call(f.Replacement.ClearFormatting) + # A valid no-match is False; an exhausted COM error must reach the + # document boundary and prevent printing a partially updated schedule. + result = self.safe_com_call( + f.Execute, + find_text, # FindText + False, # MatchCase + False, # MatchWholeWord + True, # MatchWildcards + False, # MatchSoundsLike + False, # MatchAllWordForms + True, # Forward + WD_FIND_CONTINUE, # Wrap + False, # Format + replace_text, # ReplaceWith + WD_REPLACE_ALL, # Replace + ) + if result: + logger.debug(f"Find/replace matched: '{find_text}' -> '{replace_text}'") + return bool(result) def __del__(self) -> None: """Safety net: ensure COM resources are released if not explicitly shut down. diff --git a/tests/test_config.py b/tests/test_config.py index 6bed8a3..89f7c99 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,6 +12,17 @@ class TestAppConfig: """Tests for AppConfig dataclass.""" + def test_theme_survives_settings_round_trip(self): + """Saving Rose must restore Rose instead of silently resetting the theme.""" + config = AppConfig.from_dict({"theme": "rose"}) + assert config.to_dict().get("theme") == "rose" + + def test_unsupported_or_legacy_theme_falls_back_to_dark_midnight(self): + """Old and edited settings must never select a light or unknown palette.""" + for value in (None, "light", "unknown", [], {}): + config = AppConfig.from_dict({"theme": value}) + assert config.to_dict().get("theme") == "midnight" + def test_default_values(self): """Default config should have empty strings.""" config = AppConfig() @@ -42,6 +53,7 @@ def test_to_dict(self): "day_folder": "/path/to/day", "night_folder": "/path/to/night", "printer_name": "My Printer", + "theme": "midnight", } def test_from_dict(self): diff --git a/tests/test_constants.py b/tests/test_constants.py index 5646bca..2665904 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -5,10 +5,10 @@ from src.constants import _font_families -def test_windows_uses_text_and_display_variable_font_roles(): - """Windows body copy and headings should use their intended optical roles.""" +def test_windows_uses_distinct_installed_display_and_form_fonts(): + """Expressive headings should pair with a familiar, available form face.""" with patch("sys.platform", "win32"): body, display = _font_families() - assert body == "Segoe UI Variable Text" - assert display == "Segoe UI Variable Display" + assert body == "Segoe UI" + assert display == "Bahnschrift" diff --git a/tests/test_icon.py b/tests/test_icon.py index 00aef9a..189b2aa 100644 --- a/tests/test_icon.py +++ b/tests/test_icon.py @@ -1,8 +1,11 @@ -"""The shipped icon assets must stay in spec and in palette.""" +"""Every Windows icon size must come from the same generated master.""" from pathlib import Path +import shutil +import subprocess +import sys -from PIL import Image +from PIL import Image, ImageChops ROOT = Path(__file__).resolve().parent.parent REQUIRED_ICO_SIZES = { @@ -16,11 +19,12 @@ } -def test_icon_png_is_full_resolution_rgba(): - """PyInstaller and the Tk fallback both read the PNG master.""" +def test_icon_png_is_full_resolution(): + """The generated master retains its resolution for future exports.""" with Image.open(ROOT / "icon.png") as im: - assert im.size == (1024, 1024) - assert im.mode == "RGBA" + assert im.width == im.height + assert im.width >= 1024 + assert im.mode in ("RGB", "RGBA") def test_icon_ico_carries_every_windows_size(): @@ -29,11 +33,33 @@ def test_icon_ico_carries_every_windows_size(): assert set(ico.info["sizes"]) == REQUIRED_ICO_SIZES -def test_icon_uses_the_committed_shift_tokens(): - """The icon shares the app's palette rather than inventing its own.""" - with Image.open(ROOT / "icon.png") as im: - colors = {c for _, c in im.convert("RGB").getcolors(maxcolors=1_000_000)} +def test_every_windows_frame_matches_the_png_master(): + """Title bars, taskbar, Explorer and Tk must not show different artwork.""" + with Image.open(ROOT / "icon.png") as master, Image.open(ROOT / "icon.ico") as ico: + for size in REQUIRED_ICO_SIZES: + expected = master.convert("RGBA").resize(size, Image.Resampling.LANCZOS) + actual = ico.ico.getimage(size) + assert not ImageChops.difference( + actual.convert("RGB"), expected.convert("RGB") + ).getbbox() + + +def test_export_preserves_the_source_artwork(tmp_path): + """Rebuilding the ICO must never redraw or overwrite the approved PNG.""" + tool = tmp_path / "tools" / "make_icon.py" + tool.parent.mkdir() + shutil.copyfile(ROOT / "tools" / "make_icon.py", tool) + master = tmp_path / "icon.png" + Image.new("RGB", (1024, 1024), (238, 234, 242)).save(master) + original = master.read_bytes() - assert (0x38, 0xBD, 0xF8) in colors # night_accent - assert (0xF2, 0xB3, 0x40) in colors # day_accent - assert (0x16, 0x17, 0x1A) in colors # window charcoal ground + subprocess.run([sys.executable, str(tool)], check=True, capture_output=True) + + assert master.read_bytes() == original + with Image.open(tmp_path / "icon.ico") as ico: + assert set(ico.info["sizes"]) == REQUIRED_ICO_SIZES + assert ico.ico.getimage((16, 16)).convert("RGB").getpixel((8, 8)) == ( + 238, + 234, + 242, + ) diff --git a/tests/test_main.py b/tests/test_main.py index ace9b74..a0ed928 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -57,6 +57,7 @@ def app(self): mock_ui.get_day_folder.return_value = "/tmp/day" mock_ui.get_night_folder.return_value = "/tmp/night" mock_ui.get_printer_name.return_value = "Test Printer" + mock_ui.get_theme.return_value = "rose" mock_ui.get_available_printers.return_value = ["Test Printer"] mock_ui.get_shift_selections.return_value = ( ShiftSelection( @@ -130,6 +131,7 @@ def test_validate_inputs_ignores_disabled_shift_folder(self, app): assert request is not None assert error is None + assert request.theme == "rose" mock_validate.assert_called_once_with("/tmp/night") assert [job.shift_type for job in request.manifest] == ["night"] @@ -332,6 +334,7 @@ def test_on_close_without_active_thread(self, app): app._processing_thread = None app._on_close() app.root.destroy.assert_called_once() + assert app.config_manager.save.call_args.args[0].theme == "rose" def test_on_close_with_active_thread(self, app): """Should cancel and join thread before destroying window.""" @@ -452,6 +455,68 @@ def test_process_batch_tracks_failures_with_summary(self, mock_wp_class, app): for call in app.ui.update_status.call_args_list ) + @pytest.mark.parametrize("ending", ["complete", "cancel", "exception", "close"]) + def test_batch_preserves_prior_failures_on_every_exit( + self, app, isolate_user_data_dir, ending + ): + """A stopped batch must retain exactly one CSV and summary of failed attempts.""" + word_proc = MagicMock() + word_proc.__enter__.return_value = word_proc + word_proc.__exit__.return_value = False + attempts = 0 + + def print_attempt(*_args): + nonlocal attempts + attempts += 1 + if attempts == 1: + if ending in ("cancel", "close"): + app._cancel_event.set() + if ending == "close": + app._closing = True + return False, "Printer unavailable" + if ending == "exception": + raise RuntimeError("Worker stopped unexpectedly") + return True, None + + word_proc.print_document.side_effect = print_attempt + request = _request( + PrintJob(date(2026, 1, 14), "night", "Wednesday Night", "/tmp/night"), + PrintJob(date(2026, 1, 15), "day", "THIRD Thursday", "/tmp/day"), + ) + with patch.object( + main_module, "WordProcessor", return_value=word_proc + ), patch.object( + app, "_write_failure_report", wraps=app._write_failure_report + ) as write_report, patch.object( + app, "_show_failure_summary" + ) as summary: + app._process_batch(request) + _run_scheduled_callbacks(app) + write_report.assert_called_once() + if ending == "close": + summary.assert_not_called() + else: + summary.assert_called_once() + app.ui.set_inputs_enabled.assert_called_with(True) + + assert attempts == (1 if ending in ("cancel", "close") else 2) + word_proc.__exit__.assert_called_once() + reports = list(isolate_user_data_dir.glob("failure_report_*.csv")) + assert len(reports) == 1 + with reports[0].open(newline="", encoding="utf-8") as report: + assert list(csv.DictReader(report)) == [ + { + "date": "01/14/2026", + "shift": "night", + "template": "Wednesday Night", + "error": "Printer unavailable", + } + ] + if ending == "cancel": + app.ui.update_status.assert_any_call( + "Cancelled after 1 of 2 schedules", 50, level="info" + ) + def test_write_failure_report_creates_csv(self, app, tmp_path): """_write_failure_report should create a CSV with correct headers.""" with patch("src.main.get_data_dir", return_value=tmp_path): @@ -604,6 +669,7 @@ def test_load_config_populates_entries(self, app): day_folder="/saved/day", night_folder="/saved/night", printer_name="Saved Printer", + theme="rose", ) app.config_manager.load.return_value = mock_config @@ -619,6 +685,7 @@ def test_load_config_populates_entries(self, app): app.ui.set_night_folder.assert_called_with("/saved/night") app.ui.printer_var.set.assert_called_with("Saved Printer") app.ui.refresh_setup_summary.assert_called_once() + app.ui.set_theme.assert_called_with("rose") def test_load_config_exception_shows_warning(self, app): """A load failure should give recovery guidance without raw diagnostics.""" @@ -777,6 +844,7 @@ def test_process_batch_saves_configuration_and_consumes_preflight_cache( saved.night_folder, saved.printer_name, ) == ("/tmp/day", "/tmp/night", "Test Printer") + assert saved.theme == request.theme assert app._preflight_wp is None mock_wp_class.assert_not_called() diff --git a/tests/test_ui.py b/tests/test_ui.py index 0d526d4..f0d5979 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -4,7 +4,7 @@ import tkinter as tk from datetime import date -from unittest.mock import MagicMock, patch +from unittest.mock import DEFAULT, MagicMock, patch import pytest @@ -44,6 +44,7 @@ def __init__(self, *_args, **_kwargs): self.grid_remove = MagicMock() self.bind = MagicMock() self.config = MagicMock() + self.configure = self.config def get_date(self): """Return the current date.""" @@ -61,6 +62,10 @@ class TestScheduleAppUI: def root(self): """Create a mock Tk root.""" root = MagicMock(spec=tk.Tk) + root.winfo_id.return_value = 0 + root.winfo_fpixels.return_value = 96 + root.winfo_screenwidth.return_value = 1920 + root.winfo_screenheight.return_value = 1080 return root @pytest.fixture @@ -83,15 +88,15 @@ def ui(self, root): "src.ui.ttk.Checkbutton" ), patch( "src.ui.ttk.Radiobutton" - ), patch( - "src.ui.ttk.OptionMenu" + ), patch.multiple( + "src.ui.ttk", OptionMenu=DEFAULT, Menubutton=DEFAULT ), patch( "src.ui.ttk.Progressbar" ), patch( "src.ui.ttk.Separator" - ), patch( - "src.ui.tk.Button" - ) as MockTkButton, patch( + ), patch.multiple( + "src.ui.tk", Button=DEFAULT, Menu=DEFAULT + ) as mock_tk, patch( "src.ui.tk.Canvas" ) as MockCanvas, patch( "src.ui.tk.Toplevel" @@ -104,6 +109,8 @@ def ui(self, root): ), patch( "src.ui.DateEntry", side_effect=_FakeDateEntry ): + MockToplevel.return_value.winfo_id.return_value = 0 + MockLabel.side_effect = lambda *_args, **_kwargs: MagicMock() ui = ScheduleAppUI(root, today=date(2026, 7, 30)) # Manually assign mock widgets for testing ui.day_entry = MagicMock() @@ -116,7 +123,9 @@ def ui(self, root): ui._progress_pct = MagicMock() ui.printer_var = _FakeVariable(value="Test Printer") ui._test_ttk_button_class = MockTtkButton - ui._test_tk_button_class = MockTkButton + ui._test_tk_button_class = mock_tk["Button"] + ui._footer_frame = MagicMock() + ui._footer_frame.winfo_reqheight.return_value = 0 ui._test_canvas_class = MockCanvas ui._test_scrollbar_class = MockScrollbar ui._test_frame_class = MockFrame @@ -132,6 +141,63 @@ def test_init(self, ui): assert ui.print_btn is not None ui.root.title.assert_called_with("ShiftPress") + def test_windows_dpi_is_set_before_window_creation(self): + """The process opts out of blurry bitmap scaling before Tk creates windows.""" + configure = getattr(ui_module, "configure_windows_dpi", None) + assert callable(configure) + dlls = MagicMock() + with patch.object(ui_module.sys, "platform", "win32"), patch.object( + ui_module.ctypes, "windll", dlls, create=True + ): + configure() + call = dlls.user32.SetProcessDpiAwarenessContext.call_args + assert call.args[0].value == ui_module.ctypes.c_void_p(-2).value + + def test_selection_chrome_scales_with_windows_text(self, ui): + """State indicators must not remain ten-pixel dots beside 200% text.""" + ui._scale = 2.0 + ui._configure_styles() + for control in ("Card.TCheckbutton", "Card.TRadiobutton"): + ui.style.configure.assert_any_call( + control, + indicatorsize=36, + indicatormargin=(0, 4, 20, 4), + upperbordercolor=COLORS.text_dim, + lowerbordercolor=COLORS.text_dim, + ) + + def test_windows_titlebar_gets_dark_caption_and_readable_text(self, ui): + """An OS light preference must not turn the app's caption white.""" + apply_caption = getattr(ui, "_style_titlebar", None) + assert callable(apply_caption) + dlls = MagicMock() + dlls.user32.GetParent.return_value = 123 + ui.root.winfo_id.return_value = 456 + with patch.object(ui_module.sys, "platform", "win32"), patch.object( + ui_module.ctypes, "windll", dlls, create=True + ): + apply_caption(ui.root) + calls = dlls.dwmapi.DwmSetWindowAttribute.call_args_list + assert [call.args[1] for call in calls] == [20, 35, 36] + assert calls[0].args[2]._obj.value == 1 + + def test_theme_switch_preserves_shift_choices_and_processing_lock(self, ui): + """A palette change must not reconstruct widgets or reset a print run.""" + apply_theme = getattr(ui, "set_theme", None) + assert callable(apply_theme) + ui._shift_panels["night"].mode_var.set("range") + ui._shift_panels["day"].enabled_var.set(False) + selections = ui.get_shift_selections() + ui.set_inputs_enabled(False) + apply_theme("rose") + assert ui.get_shift_selections() == selections + assert ui._inputs_enabled is False + assert ui.get_theme() == "rose" + assert ui.colors.background == "#15131B" + apply_theme("midnight") + assert ui.get_shift_selections() == selections + assert ui.get_theme() == "midnight" + def test_get_day_folder(self, ui): """Should return value from day entry.""" ui.day_entry.get.return_value = "C:/Templates/Day" @@ -315,8 +381,8 @@ def test_window_sizing_derives_from_rendered_content(self, ui, root): with patch("src.ui._get_work_area", return_value=(0, 0, 1920, 1040)): ui._apply_content_sizing() - root.minsize.assert_called_once_with(1040, 400) - root.geometry.assert_called_once_with("1040x812") + root.minsize.assert_called_once_with(900, 400) + root.geometry.assert_called_once_with("1120x812") def test_constrained_display_enables_vertical_overflow_recovery(self, ui, root): """Content taller than the usable screen must remain reachable by scrolling.""" @@ -330,7 +396,7 @@ def test_constrained_display_enables_vertical_overflow_recovery(self, ui, root): with patch("src.ui._get_work_area", return_value=(0, 0, 1366, 728)): ui._apply_content_sizing() - root.geometry.assert_called_once_with("1040x688") + root.geometry.assert_called_once_with("1120x688") ui._show_main_scrollbar.assert_called_once() def test_main_overflow_helpers_resize_and_scroll_visible_content(self, ui): @@ -373,7 +439,7 @@ def test_windows_work_area_caps_content_without_hiding_the_action(self, ui, root with patch("src.ui._get_work_area", return_value=(0, 0, 1517, 894)): ui._apply_content_sizing() - root.geometry.assert_called_once_with("1040x854") + root.geometry.assert_called_once_with("1120x854") ui._show_main_scrollbar.assert_called_once() def test_setup_dialog_caps_height_and_enables_overflow_recovery(self, ui): @@ -429,7 +495,9 @@ def test_manifest_preview_uses_actual_selected_job_count(self, ui): manifest_text = ui.manifest_label.config.call_args.kwargs["text"] assert "1. Night — 07/30/2026 — 1 document" in manifest_text assert "2. Day — 07/31/2026 — 1 document" in manifest_text - assert "Printer: Test Printer" in manifest_text + ui._manifest_printer_label.config.assert_called_with( + text="Printer\nTest Printer" + ) ui.print_btn.config.assert_any_call(text="Print 2 schedules") ui.print_btn.config.assert_any_call(state="normal") ui._shift_panels["night"].count_label.config.assert_called_with( @@ -601,8 +669,10 @@ def test_primary_action_uses_action_blue_focus_border(self, ui): ui.style.configure.assert_any_call( "Primary.TButton", background=COLORS.action, - foreground=COLORS.text_main, + foreground=COLORS.action_text, bordercolor=COLORS.action, + lightcolor=COLORS.action, + darkcolor=COLORS.action, borderwidth=1, font=FONTS.button, padding=(26, 16), @@ -620,14 +690,14 @@ def test_interactive_styles_have_explicit_keyboard_focus(self, ui): def test_group_titles_use_clean_background_and_custom_card_shells(self, ui): """Section titles should sit on the window without LabelFrame patches.""" - for style_name, foreground in ( - ("SetupTitle.TLabel", COLORS.text_main), - ("NightTitle.TLabel", COLORS.night_accent), - ("DayTitle.TLabel", COLORS.day_accent), + for style_name, foreground, background in ( + ("SetupTitle.TLabel", COLORS.text_main, COLORS.surface), + ("NightTitle.TLabel", COLORS.night_accent, COLORS.night_surface), + ("DayTitle.TLabel", COLORS.day_accent, COLORS.day_surface), ): ui.style.configure.assert_any_call( style_name, - background=COLORS.background, + background=background, foreground=foreground, font=FONTS.card_title, ) @@ -649,8 +719,10 @@ def test_generic_input_selection_does_not_borrow_day_amber(self, ui): foreground=COLORS.text_main, insertcolor=COLORS.text_main, selectbackground=COLORS.action, - selectforeground=COLORS.text_main, + selectforeground=COLORS.action_text, bordercolor=COLORS.border, + lightcolor=COLORS.border, + darkcolor=COLORS.border, borderwidth=1, padding=(8, 7), ) @@ -665,13 +737,8 @@ def test_manifest_uses_plain_bordered_frame_without_empty_title_strip(self, ui): assert len(manifest_frames) == 1 assert ui._manifest_card is not None - def test_footer_flows_after_manifest_and_logs_action_is_tertiary(self, ui): - """Footer actions should stay compact instead of pinning to the window floor.""" - ui._footer_frame.pack.assert_any_call(fill="x") - assert not any( - call.kwargs.get("side") == "bottom" - for call in ui._footer_frame.pack.call_args_list - ) + def test_secondary_actions_do_not_compete_with_print(self, ui): + """Logs stay tertiary and How to use remains in the header.""" logs_calls = [ call for call in ui._test_ttk_button_class.call_args_list @@ -686,7 +753,7 @@ def test_footer_flows_after_manifest_and_logs_action_is_tertiary(self, ui): if call.kwargs.get("text") == "How to use" ] assert len(help_calls) == 1 - assert help_calls[0].kwargs["style"] == "Tertiary.TButton" + assert help_calls[0].kwargs["style"] == "Header.TButton" def test_progress_is_hidden_until_status_work_begins(self, ui): """An idle 0% bar should not compete with the primary task.""" @@ -701,7 +768,7 @@ def test_long_manifest_and_status_copy_wraps_in_the_window(self, ui): manifest_call = next( call for call in ui._test_label_class.call_args_list - if call.kwargs.get("text") == "Printer: Choose a printer" + if call.kwargs.get("text") == "Select schedules to see the print scope" ) status_call = next( call @@ -709,7 +776,7 @@ def test_long_manifest_and_status_copy_wraps_in_the_window(self, ui): if call.kwargs.get("text") == "Complete Setup to prepare a print scope" ) - assert manifest_call.kwargs["wraplength"] == 760 + assert manifest_call.kwargs["wraplength"] == 620 assert status_call.kwargs["wraplength"] == 620 def test_processing_mode_uses_danger_style_then_restores_manifest_action(self, ui): @@ -740,15 +807,21 @@ def test_logs_action_appears_only_for_runtime_errors(self, ui): def test_setup_summary_identifies_template_folder_tails(self, ui): """Collapsed setup should identify active sources without showing long paths.""" - ui.setup_summary_label = MagicMock() + ui._setup_values = {key: MagicMock() for key in ("night", "day", "printer")} ui.refresh_setup_summary() - summary = ui.setup_summary_label.config.call_args.kwargs["text"] - assert summary == ( - "Night templates: C: › Templates › Night\n" - "Day templates: C: › Templates › Day\n" - "Printer: Test Printer" + assert ( + ui._setup_values["night"].config.call_args.kwargs["text"] + == "C: › Templates › Night" + ) + assert ( + ui._setup_values["day"].config.call_args.kwargs["text"] + == "C: › Templates › Day" + ) + assert ( + ui._setup_values["printer"].config.call_args.kwargs["text"] + == "Test Printer" ) def test_setup_fields_follow_night_then_day_workflow_order(self, ui): @@ -853,6 +926,68 @@ def test_setup_and_help_have_global_keyboard_shortcuts(self, ui): assert "" in bound_keys assert "" in bound_keys + @pytest.mark.parametrize( + "work_height,window_height,text_height,expected_height", + [(1000, 610, 550, 456), (350, 286, 226, 286)], + ) + def test_help_sizes_measured_text_within_work_area_and_preserves_scope( + self, ui, work_height, window_height, text_height, expected_height + ): + """Fixed-height help or an ignored work-area cap must fail this check.""" + dialog = MagicMock() + dialog.winfo_id.return_value = 0 + dialog.winfo_height.return_value = window_height + ui._test_toplevel_class.return_value = dialog + ui._test_toplevel_class.reset_mock() + ui.root.winfo_rootx.return_value = 100 + ui.root.winfo_rooty.return_value = 50 + selections = ui.get_shift_selections() + ui.set_inputs_enabled(False) + with patch("src.ui.tk.Text") as text_class, patch( + "src.ui._get_work_area", return_value=(0, 0, 1400, work_height) + ): + text = text_class.return_value + text.count.return_value = 380 + text.cget.return_value = 4 + text.winfo_height.return_value = text_height + ui.show_help() + # Bottom positioning reserves 48px of frame/work-area space. + expected_y = 74 if work_height == 1000 else 16 + dialog.geometry.assert_called_with( + f"640x{expected_height}+148+{expected_y}" + ) + text.count.assert_called_with("1.0", "end", "update", "ypixels") + text.configure.assert_any_call(state="disabled") + assert ui.get_shift_selections() == selections + assert ui._inputs_enabled is False + ui._hide_help_dialog() + dialog.withdraw.assert_called() + ui.show_help() + text_class.assert_called_once() + ui._test_toplevel_class.assert_called_once() + + def test_help_scrollbar_tracks_overflow_and_theme_updates_open_help(self, ui): + """Overflow recovery and palette changes must reach an existing Help view.""" + with patch("src.ui.tk.Text") as text_class: + ui._create_help_dialog() + text = text_class.return_value + sync = next( + call.kwargs["yscrollcommand"] + for call in text.configure.call_args_list + if "yscrollcommand" in call.kwargs + ) + scrollbar = ui._test_scrollbar_class.return_value + scrollbar.reset_mock() + sync("0.0", "1.0") + scrollbar.pack_forget.assert_called_once() + sync("0.0", "0.5") + scrollbar.pack.assert_called_once() + scrollbar.set.assert_called_with(0.0, 0.5) + ui.set_theme("rose") + assert text.configure.call_args.kwargs["background"] == "#15131B" + assert text.configure.call_args.kwargs["foreground"] == "#F8F3F8" + text.tag_configure.assert_any_call("heading", foreground=ui.colors.action) + def test_setup_button_mnemonic_matches_alt_s_shortcut(self, ui): """The visible Setup mnemonic must match the documented Alt+S binding.""" setup_calls = [ @@ -881,17 +1016,27 @@ def test_reset_run_restores_the_common_night_then_day_defaults(self, ui): assert night.single_picker.get_date() == date(2026, 7, 30) assert day.single_picker.get_date() == date(2026, 7, 31) - def test_show_help_explains_the_operator_flow(self, ui): - """Help copy should make selection, setup, and preflight understandable.""" - ui.show_info = MagicMock() + def test_reset_run_cannot_change_a_locked_batch(self, ui): + """A late/direct Reset callback must preserve the scope and Cancel button.""" + ui._shift_panels["day"].enabled_var.set(False) + before = ui.get_shift_selections() + ui.set_inputs_enabled(False) + ui.set_processing_mode(True) + ui.print_btn.reset_mock() + + ui._reset_run() - ui.show_help() + assert ui.get_shift_selections() == before + ui.print_btn.config.assert_not_called() + + def test_keyboard_setup_cannot_bypass_processing_lock(self, ui): + """Opening and closing Setup must not overwrite Cancel during printing.""" + ui.set_inputs_enabled(False) + ui._setup_dialog.reset_mock() + + ui._show_setup_dialog() - title, message = ui.show_info.call_args.args - assert title == "How to use ShiftPress" - assert "Night" in message - assert "Day" in message - assert "preflight" in message.lower() + ui._setup_dialog.deiconify.assert_not_called() def test_set_inputs_enabled_locks_and_restores_shift_controls(self, ui): """Processing lock state should cover every independent shift control.""" diff --git a/tests/test_ui_native.py b/tests/test_ui_native.py new file mode 100644 index 0000000..dddea9a --- /dev/null +++ b/tests/test_ui_native.py @@ -0,0 +1,369 @@ +"""Real Windows Tk widgets: mocks cannot establish calendar/theme behavior.""" + +import ctypes +import sys +import tkinter as tk +from ctypes import wintypes +from datetime import date +from unittest.mock import patch + +import pytest + +from src.constants import THEMES +from src.ui import ScheduleAppUI, configure_windows_dpi + +configure_windows_dpi() + + +@pytest.fixture(scope="module") +def tk_runtime(): + """Use one Tcl interpreter, with an isolated window for each UI test.""" + root = tk.Tk() + root.withdraw() + yield root + root.destroy() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Requires native Windows Tk") +def test_dark_themes_restyle_real_calendars_without_unlocking_inputs(tk_runtime): + """Both palettes reach tkcalendar while independent dates and locks survive.""" + root = tk.Toplevel(tk_runtime) + root.withdraw() + try: + ui = ScheduleAppUI(root, today=date(2026, 9, 5)) + night = ui._shift_panels["night"] + night.mode_var.set("range") + night.range_end_picker.set_date(date(2026, 9, 8)) + ui._sync_shift_panel_state("night") + ui._shift_panels["day"].enabled_var.set(False) + ui._sync_shift_panel_state("day") + selections = ui.get_shift_selections() + ui.set_inputs_enabled(False) + for theme in ("rose", "midnight", "rose"): + ui.set_theme(theme) + root.update_idletasks() + assert ui.get_shift_selections() == selections + assert root.cget("background") == THEMES[theme].background + assert str(night.range_start_picker.cget("state")) == "disabled" + assert ( + night.range_start_picker.cget("background") == THEMES[theme].background + ) + assert ( + ui.style.lookup("DateEntry", "fieldbackground", ("readonly",)) + == THEMES[theme].input + ) + assert ui._theme_menu.entrycget(0, "value") == "Midnight" + assert ui._theme_menu.entrycget(1, "value") == "Rose" + assert ui.style.lookup("DateEntry", "arrowcolor") == THEMES[theme].action + assert ( + str(ui.printer_dropdown["menu"].cget("background")) + == THEMES[theme].surface + ) + finally: + root.destroy() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Requires native Windows Tk") +def test_reset_is_disabled_during_a_batch_and_restored_afterward(tk_runtime): + """The real Reset button must preserve active scope and a usable Cancel.""" + root = tk.Toplevel(tk_runtime) + try: + ui = ScheduleAppUI(root, today=date(2026, 9, 5)) + ui.set_night_folder("C:/review/night") + ui.set_day_folder("C:/review/day") + ui.printer_var.set("Review fake printer") + ui._shift_panels["day"].enabled_var.set(False) + ui._sync_shift_panel_state("day") + before = ui.get_shift_selections() + + def descendants(widget): + for child in widget.winfo_children(): + yield child + yield from descendants(child) + + reset = next( + widget + for widget in descendants(root) + if widget.winfo_class() == "TButton" and widget.cget("text") == "Reset run" + ) + ui.set_inputs_enabled(False) + ui.set_processing_mode(True) + root.update() + assert "disabled" in reset.state() + reset.invoke() + assert ui.get_shift_selections() == before + assert ui.print_btn.cget("text") == "Cancel" + assert "disabled" not in ui.print_btn.state() + + ui.set_inputs_enabled(True) + ui.set_processing_mode(False) + root.update() + assert "disabled" not in reset.state() + reset.invoke() + assert all(selection.enabled for selection in ui.get_shift_selections()) + assert ui.print_btn.cget("text") == "Print 2 schedules" + finally: + root.destroy() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Requires native Windows Tk") +def test_help_uses_dark_readable_content_and_closes_with_escape(tk_runtime): + """Help must not escape the palette into an OS-light message box.""" + root = tk.Toplevel(tk_runtime) + try: + ui = ScheduleAppUI(root) + ui.show_info = lambda *_args: None + ui.set_theme("rose") + ui.show_help() + root.update() + dialog = getattr(ui, "_help_dialog", None) + assert dialog is not None + assert dialog.winfo_viewable() + assert dialog.cget("background") == THEMES["rose"].background + content = ui._help_text.get("1.0", "end") + for term in ("Night", "Day", "Apply", "Cancel", "preflight", "Alt+S"): + assert term in content + assert str(ui._help_text.cget("state")) == "disabled" + assert root.grab_current() is None # Reference help is non-modal. + ui.set_theme("midnight") + assert ui._help_text.cget("background") == THEMES["midnight"].background + ui.show_help() + assert ui._help_dialog is dialog # Reuse, do not multiply windows. + dialog.event_generate("") + root.update() + assert not dialog.winfo_viewable() + finally: + root.destroy() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Requires native Windows Tk") +def test_help_opens_without_overflow_when_the_work_area_has_room(tk_runtime): + """The preferred help size must fit the actual wrapped text, not 610px.""" + root = tk.Toplevel(tk_runtime) + try: + ui = ScheduleAppUI(root) + root.update() + for theme in ("rose", "midnight"): + ui.set_theme(theme) + ui.show_help() + root.update() + assert ui._help_text.yview() == (0.0, 1.0) + assert not any( + child.winfo_ismapped() + for child in ui._help_text.master.winfo_children() + if child.winfo_class() == "TScrollbar" + ) + ui._hide_help_dialog() + finally: + root.destroy() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Requires native Windows Tk") +def test_help_retains_scrolling_and_close_on_short_work_areas(tk_runtime): + """Content fitting must not put the close action below a small display.""" + root = tk.Toplevel(tk_runtime) + try: + ui = ScheduleAppUI(root) + root.update() + # Constrain design-space height, not raw pixels: 700px can fit all + # content at 100% scaling even though it overflows at 200%. + work_height = ui._px(360) + with patch("src.ui._get_work_area", return_value=(0, 0, 1400, work_height)): + ui.show_help() + root.update() + dialog = ui._help_dialog + assert dialog.winfo_height() <= work_height - ui._px(64) + assert ui._help_text.yview()[1] < 1 + ui._help_text.yview_moveto(1) + root.update() + assert ui._help_text.yview()[1] == 1 + close = next( + child + for frame in dialog.winfo_children() + for child in frame.winfo_children() + if child.winfo_class() == "TButton" + ) + assert close.winfo_viewable() + assert close.winfo_rooty() + close.winfo_height() <= ( + dialog.winfo_rooty() + dialog.winfo_height() + ) + finally: + root.destroy() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Requires native Windows Tk") +def test_theme_menu_switches_palette_without_changing_scope(tk_runtime): + """The button/menu pair has no editable text-selection artifact.""" + root = tk.Toplevel(tk_runtime) + try: + ui = ScheduleAppUI(root) + assert ui._theme_picker.winfo_class() == "TMenubutton" + selections = ui.get_shift_selections() + for index, theme in ((1, "rose"), (0, "midnight"), (1, "rose")): + ui._theme_menu.invoke(index) + root.update_idletasks() + assert ui.get_theme() == theme + assert ui.get_shift_selections() == selections + assert str(ui._theme_menu.cget("background")) == THEMES[theme].surface + assert str(ui._theme_menu.cget("activebackground")) == THEMES[theme].action + finally: + root.destroy() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Requires native Windows Tk") +def test_short_window_keeps_print_visible_and_allows_narrower_resize(tk_runtime): + """The action must remain on-screen while only the form scrolls.""" + root = tk.Toplevel(tk_runtime) + try: + with patch("src.ui._get_work_area", return_value=(0, 0, 2000, 1000)): + ui = ScheduleAppUI(root) + root.update() + initial_width = root.winfo_width() + assert root.minsize()[0] < initial_width + root.geometry(f"{initial_width}x600") + root.update() + assert ui.print_btn.winfo_viewable() + assert ( + ui.print_btn.winfo_rooty() + ui.print_btn.winfo_height() + <= root.winfo_rooty() + root.winfo_height() + ) + assert ui._main_scrollbar.winfo_ismapped() + assert ui._main_content.winfo_reqheight() > ui._main_canvas.winfo_height() + finally: + root.destroy() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Requires native Windows Tk") +def test_owned_titlebars_stay_dark_after_opening_and_reopening(tk_runtime): + """Tk must not discard dark chrome when Setup becomes an owned window.""" + root = tk.Toplevel(tk_runtime) + get_parent = ctypes.windll.user32.GetParent + get_parent.argtypes = [wintypes.HWND] + get_parent.restype = wintypes.HWND + get_attribute = ctypes.windll.dwmapi.DwmGetWindowAttribute + get_attribute.argtypes = [ + wintypes.HWND, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + ] + try: + ui = ScheduleAppUI(root) + root.update() # The operator opens Setup after the main window is mapped. + for theme in ("rose", "midnight", "rose"): + ui.set_theme(theme) + ui._show_setup_dialog() + root.update() + for window in (root, ui._setup_dialog): + dark = wintypes.BOOL() + assert ( + get_attribute( + get_parent(window.winfo_id()), + 20, + ctypes.byref(dark), + ctypes.sizeof(dark), + ) + == 0 + ) + assert dark.value == 1, window.title() + ui._cancel_setup_dialog() + ui.show_help() + root.update() + dark = wintypes.BOOL() + assert ( + get_attribute( + get_parent(ui._help_dialog.winfo_id()), + 20, + ctypes.byref(dark), + ctypes.sizeof(dark), + ) + == 0 + ) + assert dark.value == 1 + ui._hide_help_dialog() + finally: + root.destroy() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Requires native Windows Tk") +def test_owned_windows_share_the_shiftpress_titlebar_icon(tk_runtime): + """Owned windows must inherit ShiftPress, not Tk's feather icon.""" + root = tk.Toplevel(tk_runtime) + get_parent = ctypes.windll.user32.GetParent + get_parent.argtypes = [wintypes.HWND] + get_parent.restype = wintypes.HWND + send_message = ctypes.windll.user32.SendMessageW + send_message.argtypes = [ + wintypes.HWND, + wintypes.UINT, + wintypes.WPARAM, + wintypes.LPARAM, + ] + send_message.restype = wintypes.HANDLE + get_class_icon = ctypes.windll.user32.GetClassLongPtrW + get_class_icon.argtypes = [wintypes.HWND, ctypes.c_int] + get_class_icon.restype = wintypes.HANDLE + try: + ui = ScheduleAppUI(root) + ui._show_setup_dialog() + ui._cancel_setup_dialog() + ui.show_help() + root.update() + for size, class_index in ((0, -34), (1, -14)): + # Windows uses the class icon when WM_GETICON has no override. + icons = [ + send_message(get_parent(window.winfo_id()), 0x007F, size, 0) + or get_class_icon(get_parent(window.winfo_id()), class_index) + for window in (root, ui._setup_dialog, ui._help_dialog) + ] + assert all(icons) + assert len(set(icons)) == 1 + finally: + root.destroy() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Requires native Windows Tk") +def test_dropdowns_share_field_and_arrow_geometry(tk_runtime): + """Date pickers must not retain the narrow split-button combobox chrome.""" + root = tk.Toplevel(tk_runtime) + try: + ui = ScheduleAppUI(root) + root.update() + for theme in ("rose", "midnight"): + ui.set_theme(theme) + ui._show_setup_dialog() + root.update() + pickers = [ + ui._theme_picker, + ui.printer_dropdown, + ui._shift_panels["night"].single_picker, + ui._shift_panels["day"].single_picker, + ] + assert len({picker.winfo_reqheight() for picker in pickers}) == 1 + arrow_widths = [] + for picker in pickers: + width, height = picker.winfo_width(), picker.winfo_height() + arrow_widths.append( + sum( + picker.identify(x, height // 2) == "Menubutton.indicator" + for x in range(width) + ) + ) + assert all(arrow_widths) + assert len(set(arrow_widths)) == 1 + ui._cancel_setup_dialog() + # Reskinning must preserve tkcalendar's actual arrow-click hit target. + root.focus_force() + root.update() # Finish Setup's focus restoration before the next click. + for panel in ui._shift_panels.values(): + picker = panel.single_picker + picker.event_generate( + "", + x=picker.winfo_width() - 10, + y=picker.winfo_height() // 2, + ) + root.update() + assert picker._top_cal.winfo_viewable() + picker.drop_down() + finally: + root.destroy() diff --git a/tests/test_word_processor.py b/tests/test_word_processor.py index ab7b5cf..aa9f78e 100644 --- a/tests/test_word_processor.py +++ b/tests/test_word_processor.py @@ -4,7 +4,7 @@ import os import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, PropertyMock, patch from pathlib import Path from datetime import date @@ -361,6 +361,198 @@ def test_print_document_not_initialized(self, wp): assert success is False assert "not initialized" in error.lower() + @pytest.fixture + def printable_doc(self, wp, tmp_path): + """Keep file lookup and date processing real; fake only Word's COM objects.""" + (tmp_path / "Wednesday.docx").write_text("original template") + wp._initialized = True + wp.word_app = MagicMock() + doc = MagicMock(ProtectionType=-1) + body = MagicMock(NextStoryRange=None) + # No normalization matches; a supported date is present in the body. + body.Find.Execute.side_effect = lambda *args: bool(args[3]) + doc.StoryRanges = [body] + wp.word_app.Documents.Open.return_value = doc + return doc + + @pytest.mark.parametrize("matching_story", ["body", "header", "linked_footer"]) + def test_all_stories_are_processed_before_one_submission( + self, wp, printable_doc, tmp_path, matching_story + ): + """A date in any supported story is sufficient after all stories succeed.""" + doc = printable_doc + body = doc.StoryRanges[0] + footer = MagicMock(NextStoryRange=None) + header = MagicMock(NextStoryRange=footer) + doc.StoryRanges = [body, header] + events = [] + for name, story in ( + ("body", body), + ("header", header), + ("linked_footer", footer), + ): + + def execute(*args, story_name=name): + events.append(story_name) + return bool(args[3]) and story_name == matching_story + + story.Find.Execute.side_effect = execute + doc.PrintOut.side_effect = lambda *_args: events.append("print") + doc.Close.side_effect = lambda *_args: events.append("close") + + result = wp.print_document( + str(tmp_path), "Wednesday", date(2026, 1, 14), "Test Printer" + ) + + assert result == (True, None) + # Nine normalizations and six date patterns visit the full linked chain. + assert events == ["body", "header", "linked_footer"] * 15 + ["print", "close"] + assert body.Find.Execute.call_args_list == header.Find.Execute.call_args_list + assert header.Find.Execute.call_args_list == footer.Find.Execute.call_args_list + wp.word_app.Documents.Open.assert_called_once_with( + str(tmp_path / "Wednesday.docx"), False, True + ) + assert wp.word_app.ActivePrinter == "Test Printer" + doc.PrintOut.assert_called_once_with(False) + doc.Close.assert_called_once_with(0) + + @pytest.mark.parametrize( + "failure", ["replacement", "normalization", "collection", "linked_story"] + ) + def test_partial_date_processing_never_prints( + self, wp, printable_doc, tmp_path, failure + ): + """A body match cannot excuse failing to process another document story.""" + doc = printable_doc + body = doc.StoryRanges[0] + header = MagicMock(NextStoryRange=None) + doc.StoryRanges = [body, header] + if failure in ("replacement", "normalization"): + + def execute(*args): + if bool(args[3]) == (failure == "replacement"): + raise RuntimeError("Header date processing failed") + return False + + header.Find.Execute.side_effect = execute + elif failure == "collection": + + def stories(): + yield body + raise RuntimeError("Story collection failed") + + doc.StoryRanges = MagicMock() + doc.StoryRanges.__iter__.side_effect = stories + else: + type(body).NextStoryRange = PropertyMock( + side_effect=RuntimeError("Linked story failed") + ) + + success, error = wp.print_document( + str(tmp_path), "Wednesday", date(2026, 1, 14), "Test Printer" + ) + + assert success is False + assert "failed" in error.lower() + doc.PrintOut.assert_not_called() + doc.Close.assert_called_with(0) + assert (tmp_path / "Wednesday.docx").read_text() == "original template" + + @pytest.mark.parametrize("recovers", [True, False]) + def test_date_replacement_retries_busy_word_without_ignoring_exhaustion( + self, wp, printable_doc, tmp_path, recovers + ): + """Transient faults retry; exhausted faults block PrintOut even after a match.""" + doc = printable_doc + header = MagicMock(NextStoryRange=None) + attempts = 0 + + def execute(*args): + nonlocal attempts + if not args[3]: + return False + attempts += 1 + if not recovers or attempts == 1: + raise RuntimeError("Server is busy") + return False + + header.Find.Execute.side_effect = execute + doc.StoryRanges.append(header) + with patch("src.word_processor.time.sleep"): + success, error = wp.print_document( + str(tmp_path), "Wednesday", date(2026, 1, 14), "Test Printer" + ) + assert success is recovers + if recovers: + assert attempts == 7 # Six patterns, with the first retried once. + assert error is None + doc.PrintOut.assert_called_once_with(False) + else: + assert attempts == 5 + assert "busy" in error.lower() + doc.PrintOut.assert_not_called() + + def test_normalization_retries_the_same_operation( + self, wp, printable_doc, tmp_path + ): + """A busy normalization must be retried, not skipped before date matching.""" + find = printable_doc.StoryRanges[0].Find + first_call = True + + def execute(*args): + nonlocal first_call + if first_call: + first_call = False + raise RuntimeError("Server is busy") + return bool(args[3]) + + find.Execute.side_effect = execute + with patch("src.word_processor.time.sleep"): + result = wp.print_document( + str(tmp_path), "Wednesday", date(2026, 1, 14), "Test Printer" + ) + assert result == (True, None) + assert [call.args[0] for call in find.Execute.call_args_list[:2]] == [ + "^s", + "^s", + ] + printable_doc.PrintOut.assert_called_once_with(False) + + @pytest.mark.parametrize("close_error", ["Document close failed", "Server is busy"]) + def test_successful_submission_survives_cleanup_failure( + self, wp, printable_doc, tmp_path, close_error, caplog + ): + """An already-submitted schedule must not enter the retry/failure list.""" + printable_doc.Close.side_effect = RuntimeError(close_error) + with patch("src.word_processor.time.sleep"): + result = wp.print_document( + str(tmp_path), "Wednesday", date(2026, 1, 14), "Test Printer" + ) + assert result == (True, None) + printable_doc.PrintOut.assert_called_once_with(False) + printable_doc.Close.assert_called_with(0) + assert "Error closing document" in caplog.text + assert (tmp_path / "Wednesday.docx").read_text() == "original template" + + def test_cleanup_retry_does_not_resubmit_document( + self, wp, printable_doc, tmp_path + ): + """Recovery from a busy Close may retry cleanup only, never PrintOut.""" + printable_doc.Close.side_effect = [RuntimeError("Server is busy"), None] + with patch("src.word_processor.time.sleep"): + result = wp.print_document( + str(tmp_path), "Wednesday", date(2026, 1, 14), "Test Printer" + ) + assert result == (True, None) + printable_doc.PrintOut.assert_called_once_with(False) + assert printable_doc.Close.call_count == 2 + + def test_shutdown_never_saves_leftover_edited_documents(self, wp, printable_doc): + """If document cleanup fails, final Word shutdown must still discard edits.""" + word_app = wp.word_app + wp.shutdown() + word_app.Quit.assert_called_once_with(0) + def test_safe_com_call_rejects_zero_retries(self, wp): """safe_com_call should raise ValueError for retries < 1.""" with pytest.raises(ValueError, match="retries must be >= 1"): diff --git a/tools/make_icon.py b/tools/make_icon.py index 1369d25..33a522e 100644 --- a/tools/make_icon.py +++ b/tools/make_icon.py @@ -1,11 +1,7 @@ -"""Regenerate the ShiftPress application icon. +"""Export Windows icon sizes from the approved, image-generated icon.png. -Geometry is expressed as a fraction of the canvas, so the mark is resolution -independent. The values come from reading the mark at true 16px: a 0.020 gap -collapses the Night sheet to a sliver and 0.050 reduces it to a bare corner. - -Requires Pillow, which is a development dependency only. The running -application loads icon.ico through Tkinter and needs no imaging library. +The PNG is the canonical artwork: never redraw or overwrite it during export. +Pillow is a development dependency only; the application loads the ICO with Tk. Usage: .venv/bin/python tools/make_icon.py @@ -13,61 +9,19 @@ from pathlib import Path -from PIL import Image, ImageDraw +from PIL import Image -CANVAS = 1024 ICO_SIZES = (16, 24, 32, 48, 64, 128, 256) -GROUND = "#16171A" # window charcoal -NIGHT = "#38BDF8" # night_accent -DAY = "#F2B340" # day_accent - -TILE_RADIUS = 0.12 # "gently softened corners" per DESIGN.md Shapes -SHEET_RADIUS = 0.035 -SHEET_W = 0.42 -SHEET_H = 0.50 -OFFSET = 0.19 -GAP = 0.035 - - -def render(size: int = CANVAS) -> Image.Image: - """Draw the icon at *size* square. - - Args: - size: Edge length in pixels. - - Returns: - An RGBA image of the mark. - """ - s = size - im = Image.new("RGBA", (s, s), (0, 0, 0, 0)) - d = ImageDraw.Draw(im) - d.rounded_rectangle([0, 0, s, s], radius=int(s * TILE_RADIUS), fill=GROUND) - - r = int(s * SHEET_RADIUS) - nx0 = s * (0.5 - SHEET_W / 2 - OFFSET / 2) - ny0 = s * (0.5 - SHEET_H / 2 - OFFSET / 2) - nx1, ny1 = nx0 + s * SHEET_W, ny0 + s * SHEET_H - dx0, dy0 = nx0 + s * OFFSET, ny0 + s * OFFSET - dx1, dy1 = dx0 + s * SHEET_W, dy0 + s * SHEET_H - - # Night sheet sits behind, up and left. - d.rounded_rectangle([nx0, ny0, nx1, ny1], radius=r, fill=NIGHT) - # Ground-coloured gap keeps the two sheets legible at 16px. - g = s * GAP - d.rounded_rectangle([dx0 - g, dy0 - g, dx1 + g, dy1 + g], radius=r, fill=GROUND) - # Day sheet sits in front, down and right. - d.rounded_rectangle([dx0, dy0, dx1, dy1], radius=r, fill=DAY) - return im - def main() -> None: - """Write icon.png and icon.ico to the repository root.""" + """Write icon.ico while preserving the source PNG and its prompt metadata.""" root = Path(__file__).resolve().parent.parent - master = render() - master.save(root / "icon.png") - master.save(root / "icon.ico", sizes=[(n, n) for n in ICO_SIZES]) - print(f"wrote {root / 'icon.png'} and {root / 'icon.ico'}") + with Image.open(root / "icon.png") as master: + master.convert("RGBA").save( + root / "icon.ico", sizes=[(n, n) for n in ICO_SIZES] + ) + print(f"wrote {root / 'icon.ico'} from {root / 'icon.png'}") if __name__ == "__main__":