diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index dd9d025..179578e 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -94,9 +94,10 @@ element types and applies the evidence-bound Hz/ns contract only to LightBurn 2.1.03. The bridge binds source layer IDs, uses the host MOPA setters, serializes exact unit provenance and disables every staged step. Document preflight presents the remaining blockers and the offline driver rejects them -before buffer preparation. It never authorizes encoding. The next import task -is packaged GTK presentation; absent pulse width and image equivalence remain -blockers. +before buffer preparation. The packaged frontend now exposes that report as a +read-only Tools-menu dialog with stable blocker codes. It never authorizes +encoding. Packaged interaction evidence, absent pulse width and image +equivalence remain blockers. If a Windows artifact is available, keep the machine disconnected and run, in order: diff --git a/docs/research/lightburn-project-mopa-import.md b/docs/research/lightburn-project-mopa-import.md index 6a00702..e2d14fe 100644 --- a/docs/research/lightburn-project-mopa-import.md +++ b/docs/research/lightburn-project-mopa-import.md @@ -41,7 +41,9 @@ unverified. Rayforge's relevant Step and LaserStep files are included in the pinned upstream contract manifest. The addon now also exposes a transport-free document preflight report with -stable issue codes and human-readable blocker messages. The offline driver +stable issue codes and human-readable blocker messages. Its packaged frontend +adds a read-only `PirateRay Import Preflight` action to Rayforge's Tools menu; +the dialog renders those codes and keeps output disabled. The offline driver runs this check before it prepares encoded buffers. A staged import is rejected; manually re-enabling its step adds a distinct error, and malformed review data fails closed. There is intentionally no accepted or promoted import state yet. diff --git a/docs/research/lightburn-rayforge-ux-capability-map.md b/docs/research/lightburn-rayforge-ux-capability-map.md index 3120971..ee78f2e 100644 --- a/docs/research/lightburn-rayforge-ux-capability-map.md +++ b/docs/research/lightburn-rayforge-ux-capability-map.md @@ -70,7 +70,7 @@ required by real workshop jobs and supported by evidence. | Preserve MOPA units and values | Galvo frequency is presented in kHz and Q-pulse width in ns | Rayforge's workflow uses Hz and ns; the addon maps complete LightBurn 2.1.03 vector settings with an evidence-bound raw-Hz/display-kHz and direct-ns contract | Proven offline for complete vector settings; image/absent-pulse cases gated | Verify editing and persistence in the packaged no-device GUI while retaining the version and provenance checks | | Apply a reusable material recipe | LightBurn libraries organize presets by material, thickness and description and apply/link them to layers | Rayforge recipes already match machine, head, material, thickness and step type and preserve Hz/ns settings; the external addon now validates versioned provenance plus exact source/lens/correction compatibility while withholding hardware authorization | Proven offline at model/source level; approval policy and portable collections remain | Integrate the validated metadata into recipe selection before changing the existing recipe UI | | Inspect planned behavior before any run | LightBurn Preview reflects enabled layers and planned order | Rayforge produced a populated `JobArtifact` with Ops, path length and no machine code | Proven offline; human interaction untested | Verify layer filtering, order, hatch and warnings in the packaged no-device GUI | -| Reject a visibly invalid job | Preview/preflight should reveal unintended output; the target product must reject profile mismatch and out-of-field geometry | Existing JCZ negative tests reject profile mismatch, out-of-field geometry, invalid values and unsupported Ops | Proven offline at encoder boundary | Surface these failures as understandable preflight messages without weakening the fail-closed gate | +| Reject a visibly invalid job | Preview/preflight should reveal unintended output; the target product must reject profile mismatch and out-of-field geometry | Existing JCZ negative tests reject profile mismatch, out-of-field geometry, invalid values and unsupported Ops; the packaged addon exposes a read-only Tools-menu dialog for stable import blocker codes | Proven offline at encoder and frontend-contract boundaries; packaged interaction pending | Exercise the dialog in the packaged no-device checklist without weakening the fail-closed gate | | Keep machine and lens selection out of the normal start flow | LightBurn device settings are separate from ordinary layer work | Rayforge has machine profiles and capability-gated settings; the desired administrator-oriented placement is not interaction-tested | Product requirement | Verify default-profile startup and move profile/lens changes behind an advanced path if upstream behavior requires it | | Save and reopen without losing job intent | Project files retain artwork and layer settings | The synthetic step retained power, speed, frequency and pulse width through serialization/deserialization; full project interaction is untested | Partly proven offline | Add save/reload to the packaged no-device checklist | | Frame safely | Galvo framing is a distinct physical action used to align a job | No physical framing is authorized; galvo framing semantics and Rayforge fit remain unproven | Gated | Keep only an explicitly labelled on-canvas simulation until a reviewed hardware protocol authorizes exact behavior | diff --git a/docs/status/phase-0.md b/docs/status/phase-0.md index cd1c1e2..12cf617 100644 --- a/docs/status/phase-0.md +++ b/docs/status/phase-0.md @@ -113,7 +113,8 @@ The remaining M1 release gates are deliberately narrow: - vector project frequency and `QPulseWidth` now use a LightBurn 2.1.03, evidence-bound Hz/ns contract; absent pulse width and image equivalence stay blocked; -- packaged Windows/GTK interaction is blocked until a reviewed Rayforge +- the addon now registers a read-only GTK Tools-menu preflight action, but + packaged Windows/GTK interaction is blocked until a reviewed Rayforge runtime with PyGObject and MSYS2/UCRT64 is available; the repository build entry point fetches pinned source but never installs that runtime; - three operator-verified projects establish project-level `frequency` and diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/frontend.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/frontend.py new file mode 100644 index 0000000..6a10598 --- /dev/null +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/frontend.py @@ -0,0 +1,50 @@ +"""Packaged GTK presentation for PirateRay import preflight results.""" + +from gettext import gettext as _ + +from gi.repository import Adw, Gio + +from rayforge.core.hooks import hookimpl +from rayforge.ui_gtk.action_registry import MenuPlacement, action_registry +from rayforge.ui_gtk.actions import action_extension_registry + +from .import_preflight import inspect_imported_workflow +from .preflight_presentation import format_preflight_report + + +ADDON_NAME = "the_pirate_ray_jcz" +ACTION_NAME = "pirateray-import-preflight" + + +def _register_actions(action_manager): + action = Gio.SimpleAction.new(ACTION_NAME, None) + + def on_activate(_action, _parameter): + report = inspect_imported_workflow(action_manager.doc) + presentation = format_preflight_report(report) + dialog = Adw.MessageDialog( + transient_for=action_manager.win, + heading=presentation["heading"], + body=presentation["body"], + ) + dialog.add_response("close", _("Close")) + dialog.set_default_response("close") + dialog.set_close_response("close") + dialog.present() + + action.connect("activate", on_activate) + action_manager.win.add_action(action) + action_manager.actions[ACTION_NAME] = action + action_registry.register( + action_name=ACTION_NAME, + action=action, + addon_name=ADDON_NAME, + label=_("PirateRay Import Preflight"), + menu=MenuPlacement(menu_id="tools", priority=60), + ) + + +@hookimpl +def register_actions(_action_registry): + """Expose the read-only preflight report in Rayforge's Tools menu.""" + action_extension_registry.register_setup(_register_actions, ADDON_NAME) diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/preflight_presentation.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/preflight_presentation.py new file mode 100644 index 0000000..6b1c87d --- /dev/null +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/preflight_presentation.py @@ -0,0 +1,58 @@ +"""Build a UI-neutral presentation of PirateRay import preflight results.""" + +from __future__ import annotations + +from collections.abc import Mapping +from gettext import gettext as _ +from typing import Any + + +def _invalid_report() -> dict[str, str]: + return { + "status": "blocked", + "heading": _("Import preflight blocked"), + "body": _( + "The preflight report is malformed. Output remains disabled." + ), + } + + +def format_preflight_report(report: Any) -> dict[str, str]: + """Return localized heading and body text for the packaged GTK dialog.""" + if not isinstance(report, Mapping): + return _invalid_report() + ready = report.get("ready") + issues = report.get("issues") + if not isinstance(ready, bool) or not isinstance(issues, list): + return _invalid_report() + if ready: + if issues: + return _invalid_report() + return { + "status": "ready", + "heading": _("Import preflight ready"), + "body": _("No unresolved PirateRay import blockers were found."), + } + if not issues: + return _invalid_report() + + lines = [] + for issue in issues: + if not isinstance(issue, Mapping): + return _invalid_report() + code = issue.get("code") + message = issue.get("message") + if not isinstance(code, str) or not code: + return _invalid_report() + if not isinstance(message, str) or not message: + return _invalid_report() + lines.append(f"{code}: {message}") + + prefix = _( + "Resolve every issue before enabling output. Output remains disabled." + ) + return { + "status": "blocked", + "heading": _("Import preflight blocked"), + "body": prefix + "\n\n" + "\n".join(lines), + } diff --git a/spikes/rayforge-jcz-no-device-addon/rayforge-addon.yaml b/spikes/rayforge-jcz-no-device-addon/rayforge-addon.yaml index 4c55270..f055637 100644 --- a/spikes/rayforge-jcz-no-device-addon/rayforge-addon.yaml +++ b/spikes/rayforge-jcz-no-device-addon/rayforge-addon.yaml @@ -1,7 +1,7 @@ name: the_pirate_ray_jcz display_name: The Pirate Ray JCZ (offline spike) -description: Hardware-free binary-driver registration proof for Rayforge. -version: 0.1.0 +description: Hardware-free JCZ workflow and import-preflight integration. +version: 0.2.0 api_version: 23 depends: - rayforge>=1.0.0 @@ -9,4 +9,5 @@ author: name: The Pirate Ray contributors provides: worker: pirate_ray_jcz.worker + frontend: pirate_ray_jcz.frontend maturity: experimental diff --git a/tests/fixtures/rayforge-addon-spike.json b/tests/fixtures/rayforge-addon-spike.json index ecf5380..c2ba89e 100644 --- a/tests/fixtures/rayforge-addon-spike.json +++ b/tests/fixtures/rayforge-addon-spike.json @@ -17,8 +17,10 @@ ], "packaged_files": [ "pirate_ray_jcz/__init__.py", + "pirate_ray_jcz/frontend.py", "pirate_ray_jcz/import_preflight.py", "pirate_ray_jcz/offline_session.py", + "pirate_ray_jcz/preflight_presentation.py", "pirate_ray_jcz/profile_import.py", "pirate_ray_jcz/project_import.py", "pirate_ray_jcz/rayforge_compat.py", diff --git a/tests/test_lightburn_import_preflight.py b/tests/test_lightburn_import_preflight.py index ce9d780..998f7c1 100644 --- a/tests/test_lightburn_import_preflight.py +++ b/tests/test_lightburn_import_preflight.py @@ -9,6 +9,7 @@ sys.path.insert(0, str(ADDON)) from pirate_ray_jcz.import_preflight import ( ImportPreflightError, assert_no_unresolved_imports, inspect_imported_workflow, ) +from pirate_ray_jcz.preflight_presentation import format_preflight_report from pirate_ray_jcz.workflow_import import METADATA_KEY @@ -70,6 +71,31 @@ class ImportPreflightTests(unittest.TestCase): prepare = source.index("self.offline_session.prepare(encoded)") self.assertLess(preflight, prepare) + def test_packaged_presentation_lists_stable_blocker_codes(self): + presentation = format_preflight_report( + inspect_imported_workflow(document(imported_step())) + ) + self.assertEqual("blocked", presentation["status"]) + self.assertEqual("Import preflight blocked", presentation["heading"]) + self.assertIn("IMPORT_REVIEW_REQUIRED", presentation["body"]) + self.assertIn("Output remains disabled", presentation["body"]) + + def test_packaged_presentation_fails_closed_on_malformed_report(self): + for report in (None, {}, {"ready": True, "issues": [{}]}): + with self.subTest(report=report): + presentation = format_preflight_report(report) + self.assertEqual("blocked", presentation["status"]) + self.assertIn("malformed", presentation["body"]) + + def test_frontend_registers_read_only_tools_action(self): + source = (ADDON / "pirate_ray_jcz" / "frontend.py").read_text( + encoding="utf-8" + ) + self.assertIn('ACTION_NAME = "pirateray-import-preflight"', source) + self.assertIn('menu_id="tools"', source) + self.assertIn("inspect_imported_workflow(action_manager.doc)", source) + self.assertNotIn("machine_cmd", source) + if __name__ == "__main__": unittest.main()