diff --git a/.github/workflows/reusable-linux.yml b/.github/workflows/reusable-linux.yml index 9fc119e7..caedfb3a 100644 --- a/.github/workflows/reusable-linux.yml +++ b/.github/workflows/reusable-linux.yml @@ -76,7 +76,7 @@ jobs: working-directory: loop run: python3 scripts/ci/check_version_policy.py - name: Verify processing-budget exhaustion corpus - working-directory: loupe + working-directory: loop run: python3 scripts/budget_exhaustion/generate_corpus.py --check - name: Prepare vcpkg directories diff --git a/.github/workflows/reusable-windows.yml b/.github/workflows/reusable-windows.yml index a5e3e6bf..97300127 100644 --- a/.github/workflows/reusable-windows.yml +++ b/.github/workflows/reusable-windows.yml @@ -89,7 +89,7 @@ jobs: --page-count 256 --operations 256 --family pathological-vector - name: Verify processing-budget exhaustion corpus - working-directory: loupe + working-directory: loop shell: pwsh run: python scripts\budget_exhaustion\generate_corpus.py --check diff --git a/LoopLibQuick/CMakeLists.txt b/LoopLibQuick/CMakeLists.txt index e36422ac..14e1cfc0 100644 --- a/LoopLibQuick/CMakeLists.txt +++ b/LoopLibQuick/CMakeLists.txt @@ -56,6 +56,10 @@ qt_add_library(LoopLibQuick SHARED sources/loopcanvasitemscene.cpp sources/loopcanvasaccessible.cpp sources/loopcanvasaccessible.h + sources/looptokens.cpp + sources/looptokens.h + sources/loopstatevisual.cpp + sources/loopstatevisual.h ) # No QML_FILES. LoopCanvasItem is registered from C++ with QML_NAMED_ELEMENT, diff --git a/LoopLibQuick/sources/loopstatevisual.cpp b/LoopLibQuick/sources/loopstatevisual.cpp new file mode 100644 index 00000000..a3c093a2 --- /dev/null +++ b/LoopLibQuick/sources/loopstatevisual.cpp @@ -0,0 +1,114 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + + +#include "loopstatevisual.h" + +#include "preflightengine.h" + +namespace pdfquick::tokens +{ + +namespace +{ + +LoopStateVisual fromFinding(const pdf::PreflightFinding& finding) +{ + const QString severity = finding.severity.trimmed(); + + if (severity.compare(QLatin1String("error"), Qt::CaseInsensitive) == 0) + { + return { StateKind::Error, ColorRole::SeverityError, StateIcon::FilledCircle }; + } + if (severity.compare(QLatin1String("warning"), Qt::CaseInsensitive) == 0) + { + return { StateKind::Warning, ColorRole::SeverityWarning, StateIcon::FilledTriangle }; + } + if (severity.compare(QLatin1String("info"), Qt::CaseInsensitive) == 0) + { + return { StateKind::Info, ColorRole::SeverityInfo, StateIcon::FilledSquare }; + } + + // profile.schema.json admits only error/warning/info. A finding with + // anything else is data this build does not understand -- the safe + // reading is "cannot vouch for this", not "no problem here", so it takes + // the same never-green treatment as an incomplete check rather than + // silently falling through to Passed. + return { StateKind::Incomplete, ColorRole::StateIncomplete, StateIcon::Hatched }; +} + +LoopStateVisual fromStatus(const pdf::PreflightCheckStatus& status) +{ + if (status.status.compare(QLatin1String("ok"), Qt::CaseInsensitive) == 0) + { + return { StateKind::Passed, ColorRole::Success, StateIcon::Checkmark }; + } + + // Every other status literal this build emits -- failed, warning, skipped, + // incomplete, unsupported -- and any literal a future check adds all take + // this branch. That is deliberately coarser than the run-level verdict in + // pdf::reducePreflightVerdict(): a caller presenting one check's + // completion, without a specific finding to show, only ever needs to know + // "clean pass" from "not that", and the second must never render as the + // first. + return { StateKind::Incomplete, ColorRole::StateIncomplete, StateIcon::Hatched }; +} + +} // namespace + +LoopStateVisual resolveStateVisual(const pdf::PreflightFinding* finding, + const pdf::PreflightCheckStatus* status, + const pdf::PreflightDecision* decision, + const QString& currentDocumentDigest, + const QString& currentProfileDigest) +{ + // Checked first and unconditionally: a waived finding is presented as + // waived regardless of its severity or the check's completion status. + // resolveState() -- not the stored kind alone -- decides "active", so a + // decision recorded against a document revision or profile that no longer + // matches falls through instead of masking the finding (mirrors + // PreflightDecision::countsForSignoff(), issue #126). + if (decision != nullptr && decision->kind == pdf::PreflightDecisionKind::Waive) + { + const pdf::PreflightDecisionState state = decision->resolveState(currentDocumentDigest, currentProfileDigest); + if (state == pdf::PreflightDecisionState::Active) + { + return { StateKind::Waived, ColorRole::SeverityWarning, StateIcon::BadgeOverlay }; + } + } + + if (finding != nullptr) + { + return fromFinding(*finding); + } + + if (status != nullptr) + { + return fromStatus(*status); + } + + // No finding, no check status, no active waiver: nothing has run for this + // revision yet. + return { StateKind::NotChecked, ColorRole::StateNotChecked, StateIcon::Outline }; +} + +} // namespace pdfquick::tokens diff --git a/LoopLibQuick/sources/loopstatevisual.h b/LoopLibQuick/sources/loopstatevisual.h new file mode 100644 index 00000000..4ad4ef08 --- /dev/null +++ b/LoopLibQuick/sources/loopstatevisual.h @@ -0,0 +1,115 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + + +#ifndef LOOPSTATEVISUAL_H +#define LOOPSTATEVISUAL_H + +#include "loopquickglobal.h" +#include "looptokens.h" + +#include + +namespace pdf +{ +struct PreflightFinding; +struct PreflightCheckStatus; +struct PreflightDecision; +} // namespace pdf + +namespace pdfquick::tokens +{ + +/// The finding/check state a surface is presenting. Kept separate from +/// `ColorRole` (below) even though today it maps one-to-one, because a state +/// is a fact about a finding and a colour role is a fact about a pixel; a +/// future high-contrast or print treatment that wants to give two states the +/// same colour role must still tell them apart by `kind`. +enum class StateKind +{ + Error, + Warning, + Info, + Incomplete, + NotChecked, + Passed, + Waived +}; + +/// Shape carries the state distinction alongside colour, so the mapping +/// survives colour-blindness and greyscale printing (docs/ACCESSIBILITY_BASELINE.md, +/// issue #25). `BadgeOverlay` is drawn in addition to the underlying severity +/// treatment, not instead of it -- a waived error still shows as an error with +/// a badge, it never becomes indistinguishable from a plain warning. +enum class StateIcon +{ + FilledCircle, // Error + FilledTriangle, // Warning + FilledSquare, // Info + Hatched, // Incomplete + Outline, // Not checked + Checkmark, // Passed + BadgeOverlay // Waived +}; + +struct LoopStateVisual +{ + StateKind kind = StateKind::NotChecked; + ColorRole colorRole = ColorRole::StateNotChecked; + StateIcon icon = StateIcon::Outline; +}; + +/// Single source of truth for finding/check presentation (issue #194). Every +/// surface that draws a finding, a check row, or a run summary -- finding +/// cards, the report dock, canvas overlays, the Inspector (#127), the status +/// bar -- calls this; none derives its own colour or icon from `severity`, +/// `status`, or a decision's kind directly. +/// +/// `finding` is the specific finding being presented, or null when the caller +/// is presenting a check's overall status rather than one of its findings (for +/// example, a check row with zero findings). `status` is the +/// PreflightCheckStatus for the check `finding` belongs to (or the check being +/// summarised), or null when no run exists yet for the current document +/// revision. `decision` is the operator decision recorded against +/// `finding->stableId()`, or null when none was recorded; `currentDocumentDigest` +/// and `currentProfileDigest` are passed through to +/// `PreflightDecision::resolveState()` so a decision made against a stale +/// document or profile is never read as active (mirrors +/// `PreflightDecision::countsForSignoff()`, issue #126). +/// +/// Two invariants hold for every input combination and are asserted by +/// tst_loopstatevisualtest.cpp: +/// +/// - `StateKind::Incomplete` never resolves to the same colour role or icon +/// as `StateKind::Passed`. An incomplete check must never render as a +/// clean pass (issue #133). +/// - An active Waive decision never resolves to `StateKind::Passed`. Waived +/// always renders as `StateKind::Waived`, distinct from Passed. +LOOPLIBQUICK_EXPORT LoopStateVisual resolveStateVisual(const pdf::PreflightFinding* finding, + const pdf::PreflightCheckStatus* status, + const pdf::PreflightDecision* decision, + const QString& currentDocumentDigest = QString(), + const QString& currentProfileDigest = QString()); + +} // namespace pdfquick::tokens + +#endif // LOOPSTATEVISUAL_H diff --git a/LoopLibQuick/sources/looptokens.cpp b/LoopLibQuick/sources/looptokens.cpp new file mode 100644 index 00000000..55d5e8ef --- /dev/null +++ b/LoopLibQuick/sources/looptokens.cpp @@ -0,0 +1,216 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + + +#include "looptokens.h" + +namespace pdfquick::tokens +{ + +namespace +{ + +// Every literal below is duplicated, by design, in the table in +// docs/LOOP_DESIGN_SYSTEM.md and is contrast-checked there against its paired +// surface (WCAG 4.5:1 for text, 3:1 for icons/focus rings/large text). Values +// are compiled constants rather than parsed from JSON for the same reason +// CanvasPalette's are: a design-system component must be able to draw before +// any file on disk has been read. +// +// Dark and High Contrast mirror docs/quick-design-tokens.json and +// CanvasPalette::standard()/highContrast() (issue #178) where a role has an +// equivalent there. Light is new: this is the first Loop surface with a light +// theme. + +// Dark theme. +constexpr const char* DarkSurfaceBase = "#111827"; +constexpr const char* DarkSurfacePanel = "#1F2937"; +constexpr const char* DarkSurfaceOverlay = "#374151"; +constexpr const char* DarkTextPrimary = "#F8FAFC"; +constexpr const char* DarkTextSecondary = "#CBD5E1"; +constexpr const char* DarkTextDisabled = "#64748B"; +constexpr const char* DarkSeverityError = "#FCA5A5"; +constexpr const char* DarkSeverityWarning = "#FCD34D"; +constexpr const char* DarkSeverityInfo = "#93C5FD"; +constexpr const char* DarkSuccess = "#86EFAC"; +constexpr const char* DarkStateIncomplete = "#94A3B8"; +constexpr const char* DarkStateNotChecked = "#64748B"; +constexpr const char* DarkFocusRing = "#C4B5FD"; +constexpr const char* DarkDestructiveAction = "#DC2626"; + +// Light theme. +constexpr const char* LightSurfaceBase = "#FFFFFF"; +constexpr const char* LightSurfacePanel = "#F1F5F9"; +constexpr const char* LightSurfaceOverlay = "#E2E8F0"; +constexpr const char* LightTextPrimary = "#0F172A"; +constexpr const char* LightTextSecondary = "#475569"; +constexpr const char* LightTextDisabled = "#94A3B8"; +constexpr const char* LightSeverityError = "#B91C1C"; +constexpr const char* LightSeverityWarning = "#B45309"; +constexpr const char* LightSeverityInfo = "#1D4ED8"; +constexpr const char* LightSuccess = "#15803D"; +constexpr const char* LightStateIncomplete = "#475569"; +constexpr const char* LightStateNotChecked = "#64748B"; +constexpr const char* LightFocusRing = "#6D28D9"; +constexpr const char* LightDestructiveAction = "#B91C1C"; + +QColor hex(const char* value) +{ + return QColor(QString::fromLatin1(value)); +} + +QColor colorDark(ColorRole role) +{ + switch (role) + { + case ColorRole::SurfaceBase: + return hex(DarkSurfaceBase); + case ColorRole::SurfacePanel: + return hex(DarkSurfacePanel); + case ColorRole::SurfaceOverlay: + return hex(DarkSurfaceOverlay); + case ColorRole::TextPrimary: + return hex(DarkTextPrimary); + case ColorRole::TextSecondary: + return hex(DarkTextSecondary); + case ColorRole::TextDisabled: + return hex(DarkTextDisabled); + case ColorRole::SeverityError: + return hex(DarkSeverityError); + case ColorRole::SeverityWarning: + return hex(DarkSeverityWarning); + case ColorRole::SeverityInfo: + return hex(DarkSeverityInfo); + case ColorRole::Success: + return hex(DarkSuccess); + case ColorRole::StateIncomplete: + return hex(DarkStateIncomplete); + case ColorRole::StateNotChecked: + return hex(DarkStateNotChecked); + case ColorRole::FocusRing: + return hex(DarkFocusRing); + case ColorRole::DestructiveAction: + return hex(DarkDestructiveAction); + } + + return hex(DarkTextPrimary); +} + +QColor colorLight(ColorRole role) +{ + switch (role) + { + case ColorRole::SurfaceBase: + return hex(LightSurfaceBase); + case ColorRole::SurfacePanel: + return hex(LightSurfacePanel); + case ColorRole::SurfaceOverlay: + return hex(LightSurfaceOverlay); + case ColorRole::TextPrimary: + return hex(LightTextPrimary); + case ColorRole::TextSecondary: + return hex(LightTextSecondary); + case ColorRole::TextDisabled: + return hex(LightTextDisabled); + case ColorRole::SeverityError: + return hex(LightSeverityError); + case ColorRole::SeverityWarning: + return hex(LightSeverityWarning); + case ColorRole::SeverityInfo: + return hex(LightSeverityInfo); + case ColorRole::Success: + return hex(LightSuccess); + case ColorRole::StateIncomplete: + return hex(LightStateIncomplete); + case ColorRole::StateNotChecked: + return hex(LightStateNotChecked); + case ColorRole::FocusRing: + return hex(LightFocusRing); + case ColorRole::DestructiveAction: + return hex(LightDestructiveAction); + } + + return hex(LightTextPrimary); +} + +// Pure black/white plus fully saturated hues, the same recipe +// CanvasPalette::highContrast() uses: hue keeps distinguishing severities for a +// reader who can see it, and every stroke/ring this feeds is widened at the +// drawing site so the reader who cannot see it is carried by shape and width +// instead (must_not_depend_on_color_alone). +QColor colorHighContrast(ColorRole role) +{ + switch (role) + { + case ColorRole::SurfaceBase: + case ColorRole::SurfacePanel: + case ColorRole::SurfaceOverlay: + return QColor(Qt::black); + + case ColorRole::TextPrimary: + case ColorRole::TextSecondary: + case ColorRole::TextDisabled: + return QColor(Qt::white); + + case ColorRole::SeverityError: + case ColorRole::DestructiveAction: + return QColor(Qt::red); + + case ColorRole::SeverityWarning: + case ColorRole::FocusRing: + return QColor(Qt::yellow); + + case ColorRole::SeverityInfo: + return QColor(Qt::cyan); + + case ColorRole::Success: + return QColor(Qt::green); + + // Deliberately not a severity hue: high contrast must not make an + // incomplete check look like a coloured severity finding. Shape (hatch + // / outline) carries the distinction here, same as in the other themes. + case ColorRole::StateIncomplete: + case ColorRole::StateNotChecked: + return QColor(Qt::white); + } + + return QColor(Qt::white); +} + +} // namespace + +QColor color(ColorRole role, LoopTheme theme) +{ + switch (theme) + { + case LoopTheme::Dark: + return colorDark(role); + case LoopTheme::Light: + return colorLight(role); + case LoopTheme::HighContrast: + return colorHighContrast(role); + } + + return colorDark(role); +} + +} // namespace pdfquick::tokens diff --git a/LoopLibQuick/sources/looptokens.h b/LoopLibQuick/sources/looptokens.h new file mode 100644 index 00000000..3008dd4f --- /dev/null +++ b/LoopLibQuick/sources/looptokens.h @@ -0,0 +1,97 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + + +#ifndef LOOPTOKENS_H +#define LOOPTOKENS_H + +#include "loopquickglobal.h" + +#include + +namespace pdfquick::tokens +{ + +// Spacing -- 4px base grid. Mirrors docs/quick-design-tokens.json `spacing.values_px`, +// which scripts/verify-quick-shell-policy.py checks. Call sites use these names, never +// a bare pixel literal, so the grid can move by editing one line. +inline constexpr int SpaceXs = 4; +inline constexpr int SpaceS = 8; +inline constexpr int SpaceM = 12; +inline constexpr int SpaceL = 16; +inline constexpr int SpaceXl = 24; +inline constexpr int SpaceXxl = 32; + +/// The theme a `ColorRole` resolves against. `HighContrast` is a distinct theme +/// rather than a flag on `Dark`/`Light`: every role has a value in all three, and +/// the state mapping's colour-independence rule (severity is also encoded in +/// icon shape, per resolveStateVisual()) only has to be verified once here. +enum class LoopTheme +{ + Dark, + Light, + HighContrast +}; + +/// Semantic colour role. Named for what a surface or piece of text *is*, never +/// for a colour -- the same split `CanvasPalette` uses for the canvas overlay +/// layer, extended to every other Loop surface (finding cards, inspector rows, +/// status bar, dialogs). A call site that reaches for a raw QColor or a hex +/// literal instead of a role is a design-system violation, not a shortcut. +enum class ColorRole +{ + SurfaceBase, + SurfacePanel, + SurfaceOverlay, + + TextPrimary, + TextSecondary, + TextDisabled, + + SeverityError, + SeverityWarning, + SeverityInfo, + + /// The "no findings" treatment. Distinct from `StateIncomplete` and + /// `StateNotChecked` by more than hue -- see resolveStateVisual(). + Success, + + /// A check that did not run to completion (budget exceeded, skipped, + /// unsupported). NOT a severity: never resolves to the `Success` role. + StateIncomplete, + + /// No run exists yet for this revision. Never the `Success` role. + StateNotChecked, + + FocusRing, + DestructiveAction +}; + +/// Resolves one semantic role to a concrete colour for `theme`. The only place +/// in the Loop UI that is allowed to know a hex value; every other surface goes +/// through this function (or through a component built on it, such as +/// resolveStateVisual()). +LOOPLIBQUICK_EXPORT QColor color(ColorRole role, LoopTheme theme); + +} // namespace pdfquick::tokens + +#endif // LOOPTOKENS_H diff --git a/UnitTests/CMakeLists.txt b/UnitTests/CMakeLists.txt index 3ab2ae46..70b5b627 100644 --- a/UnitTests/CMakeLists.txt +++ b/UnitTests/CMakeLists.txt @@ -589,6 +589,39 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY) add_test(UnitTestsOverprintRender "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsOverprintRender") endif() +# Guarded the same way LoopLibQuick's own add_subdirectory() is (see the top-level +# CMakeLists.txt): the tools/legacy-host build (LOOP_BUILD_ONLY_CORE_LIBRARY, or +# LOOP_BUILD_QUICK_CANVAS off) must not descend into anything that requires it. +# +# This compiles the design-system token/state-mapping sources directly rather +# than linking the LoopLibQuick target: they depend on QColor only, not on +# Qt Quick/Qml, and linking the SHARED LoopLibQuick library from a plain +# add_executable() test would be the first such link edge in this file -- +# every other LoopLibQuick consumer uses qt_add_executable() plus +# qt_import_qml_plugins() (see ProductQuickAccessibilitySmoke/CMakeLists.txt), +# neither of which this test needs. +if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY AND LOOP_BUILD_QUICK_CANVAS) + add_executable(UnitTestsLoopStateVisual + tst_loopstatevisualtest.cpp + ${CMAKE_SOURCE_DIR}/LoopLibQuick/sources/looptokens.cpp + ${CMAKE_SOURCE_DIR}/LoopLibQuick/sources/loopstatevisual.cpp + ) + + target_include_directories(UnitTestsLoopStateVisual PRIVATE + ${CMAKE_SOURCE_DIR}/LoopLibQuick/sources + ${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR} + ) + target_link_libraries(UnitTestsLoopStateVisual PRIVATE LoopLibCore Qt6::Core Qt6::Gui Qt6::Test) + + set_target_properties(UnitTestsLoopStateVisual PROPERTIES + WIN32_EXECUTABLE OFF + MACOSX_BUNDLE OFF + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_LIB_DIR} + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR} + ) + add_test(UnitTestsLoopStateVisual "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsLoopStateVisual") +endif() + add_executable(UnitTestsPageMasterExport tst_pagemasterexporttest.cpp ) diff --git a/UnitTests/testdata/budget-exhaustion/manifest.json b/UnitTests/testdata/budget-exhaustion/manifest.json index 12642000..80f90486 100644 --- a/UnitTests/testdata/budget-exhaustion/manifest.json +++ b/UnitTests/testdata/budget-exhaustion/manifest.json @@ -187,6 +187,6 @@ } ], "generated_by": "scripts/budget_exhaustion/generate_corpus.py", - "schema_kind": "loupe-processing-budget-exhaustion-corpus", + "schema_kind": "loop-processing-budget-exhaustion-corpus", "schema_version": 2 } diff --git a/UnitTests/tst_budgetexhaustiontest.cpp b/UnitTests/tst_budgetexhaustiontest.cpp index b7334d86..04ea24f1 100644 --- a/UnitTests/tst_budgetexhaustiontest.cpp +++ b/UnitTests/tst_budgetexhaustiontest.cpp @@ -130,7 +130,7 @@ QList loadCorpus() } const QJsonObject root = document.object(); - if (root.value(QStringLiteral("schema_kind")).toString() != QLatin1String("loupe-processing-budget-exhaustion-corpus") || root.value(QStringLiteral("schema_version")).toInt() != 2) + if (root.value(QStringLiteral("schema_kind")).toString() != QLatin1String("loop-processing-budget-exhaustion-corpus") || root.value(QStringLiteral("schema_version")).toInt() != 2) { qFatal("Unexpected generated budget exhaustion corpus schema"); } diff --git a/UnitTests/tst_loopstatevisualtest.cpp b/UnitTests/tst_loopstatevisualtest.cpp new file mode 100644 index 00000000..e956df76 --- /dev/null +++ b/UnitTests/tst_loopstatevisualtest.cpp @@ -0,0 +1,292 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "loopstatevisual.h" +#include "preflightengine.h" + +#include + +using pdfquick::tokens::ColorRole; +using pdfquick::tokens::LoopStateVisual; +using pdfquick::tokens::resolveStateVisual; +using pdfquick::tokens::StateIcon; +using pdfquick::tokens::StateKind; + +Q_DECLARE_METATYPE(StateKind) +Q_DECLARE_METATYPE(ColorRole) +Q_DECLARE_METATYPE(StateIcon) + +namespace +{ + +// 64 hex characters -- the only shape PreflightDecision::resolveState() +// accepts as a digest. The two decisions below are otherwise identical; only +// which of these two digests they were recorded against differs. +QString documentDigestA() +{ + return QString(64, QLatin1Char('a')); +} + +QString documentDigestB() +{ + return QString(64, QLatin1Char('b')); +} + +QString profileDigest() +{ + return QString(64, QLatin1Char('c')); +} + +pdf::PreflightFinding findingWithSeverity(const QString& severity) +{ + pdf::PreflightFinding finding; + finding.scope = QStringLiteral("page"); + finding.page = 1; + finding.type = QStringLiteral("color-mode"); + finding.severity = severity; + finding.checkId = QStringLiteral("color-mode"); + finding.message = QStringLiteral("test finding"); + return finding; +} + +pdf::PreflightCheckStatus statusWith(const QString& status) +{ + pdf::PreflightCheckStatus checkStatus; + checkStatus.id = QStringLiteral("color-mode"); + checkStatus.status = status; + return checkStatus; +} + +pdf::PreflightDecision waiveDecision(const QString& documentDigest) +{ + pdf::PreflightDecision decision; + decision.findingId = QStringLiteral("finding-1"); + decision.kind = pdf::PreflightDecisionKind::Waive; + decision.justification = QStringLiteral("accepted for this release"); + decision.operatorIdentity = QStringLiteral("qa@example.com"); + decision.timestampUtc = QDateTime::currentDateTimeUtc(); + decision.documentRevisionDigest = documentDigest; + decision.effectiveProfileDigest = profileDigest(); + return decision; +} + +pdf::PreflightDecision decisionOfKind(pdf::PreflightDecisionKind kind) +{ + pdf::PreflightDecision decision = waiveDecision(documentDigestA()); + decision.kind = kind; + return decision; +} + +} // namespace + +class LoopStateVisualTest : public QObject +{ + Q_OBJECT + +private slots: + void severityMapping_data(); + void severityMapping(); + + void checkStatusMapping_data(); + void checkStatusMapping(); + + void notChecked_whenNothingProvided(); + + void activeWaive_overridesSeverity(); + void staleWaive_fallsThroughToSeverity(); + void nonWaiveDecision_doesNotWaive_data(); + void nonWaiveDecision_doesNotWaive(); + + void incompleteNeverResolvesToPassed_data(); + void incompleteNeverResolvesToPassed(); + + void waivedNeverResolvesToPassed(); +}; + +void LoopStateVisualTest::severityMapping_data() +{ + QTest::addColumn("severity"); + QTest::addColumn("expectedKind"); + QTest::addColumn("expectedRole"); + QTest::addColumn("expectedIcon"); + + QTest::newRow("error") << QStringLiteral("error") << StateKind::Error << ColorRole::SeverityError << StateIcon::FilledCircle; + QTest::newRow("warning") << QStringLiteral("warning") << StateKind::Warning << ColorRole::SeverityWarning << StateIcon::FilledTriangle; + QTest::newRow("info") << QStringLiteral("info") << StateKind::Info << ColorRole::SeverityInfo << StateIcon::FilledSquare; + // profile.schema.json admits only error/warning/info; anything else is + // unrecognised and must not be silently treated as a pass. + QTest::newRow("unrecognised severity") << QStringLiteral("catastrophic") << StateKind::Incomplete << ColorRole::StateIncomplete << StateIcon::Hatched; + QTest::newRow("empty severity") << QString() << StateKind::Incomplete << ColorRole::StateIncomplete << StateIcon::Hatched; +} + +void LoopStateVisualTest::severityMapping() +{ + QFETCH(QString, severity); + QFETCH(StateKind, expectedKind); + QFETCH(ColorRole, expectedRole); + QFETCH(StateIcon, expectedIcon); + + const pdf::PreflightFinding finding = findingWithSeverity(severity); + const LoopStateVisual visual = resolveStateVisual(&finding, nullptr, nullptr); + + QCOMPARE(visual.kind, expectedKind); + QCOMPARE(visual.colorRole, expectedRole); + QCOMPARE(visual.icon, expectedIcon); +} + +void LoopStateVisualTest::checkStatusMapping_data() +{ + QTest::addColumn("status"); + QTest::addColumn("expectedKind"); + + QTest::newRow("ok") << QStringLiteral("ok") << StateKind::Passed; + QTest::newRow("failed") << QStringLiteral("failed") << StateKind::Incomplete; + QTest::newRow("warning status") << QStringLiteral("warning") << StateKind::Incomplete; + QTest::newRow("skipped") << QStringLiteral("skipped") << StateKind::Incomplete; + QTest::newRow("incomplete") << QStringLiteral("incomplete") << StateKind::Incomplete; + QTest::newRow("unsupported") << QStringLiteral("unsupported") << StateKind::Incomplete; +} + +void LoopStateVisualTest::checkStatusMapping() +{ + QFETCH(QString, status); + QFETCH(StateKind, expectedKind); + + const pdf::PreflightCheckStatus checkStatus = statusWith(status); + const LoopStateVisual visual = resolveStateVisual(nullptr, &checkStatus, nullptr); + + QCOMPARE(visual.kind, expectedKind); + if (expectedKind == StateKind::Passed) + { + QCOMPARE(visual.colorRole, ColorRole::Success); + QCOMPARE(visual.icon, StateIcon::Checkmark); + } + else + { + QCOMPARE(visual.colorRole, ColorRole::StateIncomplete); + QCOMPARE(visual.icon, StateIcon::Hatched); + } +} + +void LoopStateVisualTest::notChecked_whenNothingProvided() +{ + const LoopStateVisual visual = resolveStateVisual(nullptr, nullptr, nullptr); + QCOMPARE(visual.kind, StateKind::NotChecked); + QCOMPARE(visual.colorRole, ColorRole::StateNotChecked); + QCOMPARE(visual.icon, StateIcon::Outline); +} + +void LoopStateVisualTest::activeWaive_overridesSeverity() +{ + const pdf::PreflightFinding finding = findingWithSeverity(QStringLiteral("error")); + const pdf::PreflightDecision decision = waiveDecision(documentDigestA()); + + const LoopStateVisual visual = resolveStateVisual(&finding, nullptr, &decision, documentDigestA(), profileDigest()); + + QCOMPARE(visual.kind, StateKind::Waived); + QCOMPARE(visual.colorRole, ColorRole::SeverityWarning); + QCOMPARE(visual.icon, StateIcon::BadgeOverlay); +} + +void LoopStateVisualTest::staleWaive_fallsThroughToSeverity() +{ + const pdf::PreflightFinding finding = findingWithSeverity(QStringLiteral("error")); + const pdf::PreflightDecision decision = waiveDecision(documentDigestA()); + + // Recorded against document A; the current document is B. resolveState() + // reads this as StaleDocument, not Active, so the finding must fall + // through to its plain severity treatment rather than staying masked as + // waived. + const LoopStateVisual visual = resolveStateVisual(&finding, nullptr, &decision, documentDigestB(), profileDigest()); + + QCOMPARE(visual.kind, StateKind::Error); + QCOMPARE(visual.colorRole, ColorRole::SeverityError); +} + +void LoopStateVisualTest::nonWaiveDecision_doesNotWaive_data() +{ + QTest::addColumn("kind"); + + QTest::newRow("Accept") << static_cast(pdf::PreflightDecisionKind::Accept); + QTest::newRow("Override") << static_cast(pdf::PreflightDecisionKind::Override); + QTest::newRow("Reject") << static_cast(pdf::PreflightDecisionKind::Reject); + QTest::newRow("Reopen") << static_cast(pdf::PreflightDecisionKind::Reopen); +} + +void LoopStateVisualTest::nonWaiveDecision_doesNotWaive() +{ + QFETCH(int, kind); + + const pdf::PreflightFinding finding = findingWithSeverity(QStringLiteral("warning")); + const pdf::PreflightDecision decision = decisionOfKind(static_cast(kind)); + + const LoopStateVisual visual = resolveStateVisual(&finding, nullptr, &decision, documentDigestA(), profileDigest()); + + // Only an active Waive resolves to Waived; every other decision kind + // leaves the finding's own severity as the presentation. + QCOMPARE(visual.kind, StateKind::Warning); + QCOMPARE(visual.colorRole, ColorRole::SeverityWarning); +} + +void LoopStateVisualTest::incompleteNeverResolvesToPassed_data() +{ + QTest::addColumn("status"); + + QTest::newRow("failed") << QStringLiteral("failed"); + QTest::newRow("warning") << QStringLiteral("warning"); + QTest::newRow("skipped") << QStringLiteral("skipped"); + QTest::newRow("incomplete") << QStringLiteral("incomplete"); + QTest::newRow("unsupported") << QStringLiteral("unsupported"); + QTest::newRow("unrecognised") << QStringLiteral("not-a-real-status"); +} + +void LoopStateVisualTest::incompleteNeverResolvesToPassed() +{ + QFETCH(QString, status); + + const pdf::PreflightCheckStatus checkStatus = statusWith(status); + const LoopStateVisual visual = resolveStateVisual(nullptr, &checkStatus, nullptr); + + QVERIFY(visual.kind != StateKind::Passed); + QVERIFY(visual.colorRole != ColorRole::Success); + QVERIFY(visual.icon != StateIcon::Checkmark); +} + +void LoopStateVisualTest::waivedNeverResolvesToPassed() +{ + for (const QString& severity : { QStringLiteral("error"), QStringLiteral("warning"), QStringLiteral("info") }) + { + const pdf::PreflightFinding finding = findingWithSeverity(severity); + const pdf::PreflightDecision decision = waiveDecision(documentDigestA()); + + const LoopStateVisual visual = resolveStateVisual(&finding, nullptr, &decision, documentDigestA(), profileDigest()); + + QVERIFY(visual.kind != StateKind::Passed); + QVERIFY(visual.colorRole != ColorRole::Success); + QVERIFY(visual.icon != StateIcon::Checkmark); + QCOMPARE(visual.kind, StateKind::Waived); + } +} + +QTEST_APPLESS_MAIN(LoopStateVisualTest) + +#include "tst_loopstatevisualtest.moc" diff --git a/agent-policy.json b/agent-policy.json index a1c41a4f..203f5b44 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -57,6 +57,7 @@ "LoopLibCore/**", "UnitTests/tst_bleedfixuptest.cpp", "UnitTests/tst_budgetcorpustest.cpp", + "UnitTests/tst_budgetexhaustiontest.cpp", "UnitTests/tst_documentsessiontest.cpp", "UnitTests/tst_incrementalsavetest.cpp", "UnitTests/tst_overprinttest.cpp", @@ -153,10 +154,11 @@ "UnitTests/tst_productoperatorloop.cpp", "UnitTests/tst_quickaccessibilitytest.cpp", "UnitTests/tst_shellkeyboardtest.cpp", + "UnitTests/tst_loopstatevisualtest.cpp", "ProductQuickAccessibilitySmoke/**" ], "targets": ["LoopLibQuick", "LoopEditor", "LoopEditorQuick", "ProductQuickAccessibilitySmoke"], - "tests": ["UnitTestsQuickCanvas", "UnitTestsCanvasParity", "UnitTestsEditorHost", "UnitTestsDocumentViewSession", "UnitTestsProductOperatorLoop", "UnitTestsQuickAccessibility", "UnitTestsShellKeyboard", "UnitTestsP4S9Interaction"] + "tests": ["UnitTestsQuickCanvas", "UnitTestsCanvasParity", "UnitTestsEditorHost", "UnitTestsDocumentViewSession", "UnitTestsProductOperatorLoop", "UnitTestsQuickAccessibility", "UnitTestsShellKeyboard", "UnitTestsP4S9Interaction", "UnitTestsLoopStateVisual"] }, "developer_widgets": { "paths": [ diff --git a/changes/cc-hopeful-galileo-k8b2cu.md b/changes/cc-hopeful-galileo-k8b2cu.md new file mode 100644 index 00000000..a92441d9 --- /dev/null +++ b/changes/cc-hopeful-galileo-k8b2cu.md @@ -0,0 +1,15 @@ +# Loop UI design system tokens and canonical state mapping + +Category: added +Audience: developers +Breaking-Change: no +Summary: Add the Loop UI design system's load-bearing pieces for issue #194: +`pdfquick::tokens` semantic spacing/colour-role tokens with dark, light, and +high-contrast values (`LoopLibQuick/sources/looptokens.h`), the canonical +`resolveStateVisual()` finding/check presentation mapping +(`LoopLibQuick/sources/loopstatevisual.h`) with a table-driven test asserting +incomplete checks and waived findings never resolve to the passed treatment, +and `docs/LOOP_DESIGN_SYSTEM.md` documenting the tokens, the mapping, and +current adoption state. Component implementations and their consuming +surfaces (#193, #195, #196, #127) are still open and out of this change's +scope. diff --git a/docs/LOOP_DESIGN_SYSTEM.md b/docs/LOOP_DESIGN_SYSTEM.md new file mode 100644 index 00000000..6f753010 --- /dev/null +++ b/docs/LOOP_DESIGN_SYSTEM.md @@ -0,0 +1,179 @@ +# Loop UI design system + +Issue #194. Defines the semantic tokens and the canonical finding/check state +mapping shared by every Loop surface, and records the current adoption state. + +## Naming note + +Issue #194 was written against an earlier snapshot of this fork, before the +Qt Widgets GUI was retired and the product was renamed to Loop (see the +`changes/` fragments for that history). It names paths under `Pdf4QtLibGui/…` +and `Pdf4QtEditorPlugins/…` and a namespace prefixed with the product's old +name, none of which exist any more. This document and the code it describes +use the repository's current naming instead of the issue's literal text: +`pdfquick::tokens` in `LoopLibQuick`, `Loop`-prefixed types, and this file at +`docs/LOOP_DESIGN_SYSTEM.md`. `#193`, `#195`, `#196`, and `#127` carry the +same stale paths and will need the same translation when they are picked up. + +`docs/quick-design-tokens.json` (issue #178, ADR-007 P4-S5) already defined a +provisional colour/spacing/motion contract for the first Quick slice, checked +by `scripts/verify-quick-shell-policy.py`, and `LoopLibQuick/sources/canvaspalette.h` +already turns it into canvas overlay styling. This design system extends that +contract to a full semantic role set and to every non-canvas surface rather +than replacing it: the dark-theme colour values below are the same values, +and `CanvasPalette` continues to own canvas-specific stroke widths. + +## Tokens + +`LoopLibQuick/sources/looptokens.h`, namespace `pdfquick::tokens`. + +### Spacing + +4px base grid, matching `docs/quick-design-tokens.json` `spacing.values_px`. + +| Token | Value | +|---|---| +| `SpaceXs` | 4px | +| `SpaceS` | 8px | +| `SpaceM` | 12px | +| `SpaceL` | 16px | +| `SpaceXl` | 24px | +| `SpaceXxl` | 32px | + +### Colour roles + +Call sites name a `ColorRole` and a `LoopTheme`; `tokens::color(role, theme)` +resolves it. No call site outside `looptokens.cpp` hardcodes a colour. + +`HighContrast` is a third theme, not a flag on `Dark`/`Light` — every role has +a value in all three. Its hue choices intentionally mirror +`CanvasPalette::highContrast()`. + +Every pair below is a foreground role against the `SurfaceBase` background of +its theme, checked with the same relative-luminance contrast formula +`scripts/verify-quick-shell-policy.py` uses (WCAG 2.1): 4.5:1 minimum for text +roles, 3:1 minimum for icon/focus-ring/large-text roles. `TextDisabled` is +exempt per WCAG 1.4.3's disabled-content exception. + +| Role | Dark | Light | High contrast | Contrast (dark / light) | +|---|---|---|---|---| +| `SurfaceBase` | `#111827` | `#FFFFFF` | black | — | +| `SurfacePanel` | `#1F2937` | `#F1F5F9` | black | — | +| `SurfaceOverlay` | `#374151` | `#E2E8F0` | black | — | +| `TextPrimary` | `#F8FAFC` | `#0F172A` | white | 16.96:1 / 17.85:1 | +| `TextSecondary` | `#CBD5E1` | `#475569` | white | 11.95:1 / 7.58:1 | +| `TextDisabled` | `#64748B` | `#94A3B8` | white | exempt | +| `SeverityError` | `#FCA5A5` | `#B91C1C` | red | 9.35:1 / 6.47:1 | +| `SeverityWarning` | `#FCD34D` | `#B45309` | yellow | 12.30:1 / 5.02:1 | +| `SeverityInfo` | `#93C5FD` | `#1D4ED8` | cyan | 9.84:1 / 6.70:1 | +| `Success` | `#86EFAC` | `#15803D` | green | 12.63:1 / 5.02:1 | +| `StateIncomplete` | `#94A3B8` | `#475569` | white | 6.92:1 / 7.58:1 | +| `StateNotChecked` | `#64748B` | `#64748B` | white | 3.73:1 / 4.76:1 | +| `FocusRing` | `#C4B5FD` | `#6D28D9` | yellow | 9.61:1 / 7.10:1 | +| `DestructiveAction` | `#DC2626` | `#B91C1C` | red | — (button fill; see below) | + +`DestructiveAction` is a fill colour, not a foreground-on-`SurfaceBase` pair: +white text on `#DC2626` (dark) is 4.83:1, white text on `#B91C1C` (light) is +6.47:1, both above the 4.5:1 text minimum. + +`FocusRing` is deliberately a distinct hue (violet) from `SeverityWarning` +(amber) in both themes. `CanvasPalette` currently reuses one colour +(`m_focus`) for both the focus ring and warning-severity strokes; this is a +known divergence from the canonical roles, tracked as adoption work below +rather than changed here, since canvas overlay styling is out of this issue's +scope and any change there needs its own visual-regression pass. + +`StateIncomplete` and `StateNotChecked` are deliberately close in hue (both +neutral slate) but not identical, and `StateIncomplete` is always the more +contrasted of the two against its theme's background: it needs to draw more +attention than "no run yet", but neither one may ever be mistaken for +`Success` — see the state mapping below. + +## The canonical state mapping + +`LoopLibQuick/sources/loopstatevisual.h`, `pdfquick::tokens::resolveStateVisual()`. +One function, called by every surface; nothing else derives its own +presentation from `severity`, `PreflightCheckStatus::status`, or a decision's +kind. + +| State | Source | Colour role | Icon | Never | +|---|---|---|---|---| +| Error | `PreflightFinding::severity == "error"` | `SeverityError` | filled circle | — | +| Warning | `severity == "warning"` | `SeverityWarning` | filled triangle | — | +| Info | `severity == "info"` | `SeverityInfo` | filled square | — | +| Incomplete | `PreflightCheckStatus::status != "ok"`, or a finding with an unrecognised severity string | `StateIncomplete` | hatched | **never green, never a checkmark** | +| Not checked | no finding, no status, and no active waiver for this revision | `StateNotChecked` | outline | never green | +| Passed | `PreflightCheckStatus::status == "ok"`, no finding | `Success` | checkmark | — | +| Waived | an active `Waive` decision recorded against the finding | `SeverityWarning` + badge | badge overlay | **never the passed treatment** | + +`resolveStateVisual(finding, status, decision, currentDocumentDigest, currentProfileDigest)` +takes three optional pointers plus the two digests `PreflightDecision::resolveState()` +needs to tell an active decision from a stale one (same shape as +`PreflightDecision::countsForSignoff()`, issue #126). Precedence, checked in +this order: + +1. `decision` is a `Waive` and `decision->resolveState(...)` is `Active` → + **Waived**, regardless of the finding's severity or the check's status. +2. Otherwise, `finding` is non-null → mapped by `severity`. An unrecognised + severity string (something outside the `profile.schema.json` enum) takes + the **Incomplete** treatment rather than being silently dropped or shown + as a pass. +3. Otherwise, `status` is non-null → **Passed** only when `status == "ok"`; + every other literal (`failed`, `warning`, `skipped`, `incomplete`, + `unsupported`, and anything a future check adds) is **Incomplete**. This is + deliberately coarser than the run-level verdict in + `pdf::reducePreflightVerdict()` (`docs/PREFLIGHT_VERDICT.md`): a caller + presenting one check's completion without a specific finding only needs + "clean pass" separated from "not that". +4. Otherwise → **Not checked**. + +The two invariants this table exists to guarantee — an incomplete check never +renders as a pass, and a waived finding never renders as a pass — are +asserted by a table-driven test, `UnitTests/tst_loopstatevisualtest.cpp` +(`UnitTestsLoopStateVisual`), over the combinations in the precedence list +above plus the schema's severity values and out-of-schema inputs. + +`profile.schema.json`'s restriction-scoped statuses (`not_inspected`, +`not_applicable`) referenced by issue #194's original table belong to issue +#125, which is not yet implemented; `PreflightCheckStatus::status` today only +emits `ok`/`failed`/`warning`/`skipped`/`incomplete`/`unsupported`. All of +them already resolve correctly through rule 3 above (anything but `ok` is +Incomplete), so #125 landing a new status literal does not require a change +here — only a new named branch if a future surface wants a more specific +Incomplete presentation for it. + +## Components + +Not delivered by this issue. `StateKind` and `ColorRole` above are the +contract a component needs; the reusable finding card, inspector row, canvas +overlay, progress, empty-state, error-state, and destructive-confirm +implementations described in issue #194 §3 have no consuming surface yet +(`#193` shell, `#195` preflight workflow, `#196` canvas navigation, and `#127` +Inspector are all still open and unimplemented). Building fixtures for +components with no host would be speculative; each should land with its +consuming surface, built on `resolveStateVisual()` and the token roles above, +so the mapping is adopted rather than re-derived. + +## Theme and high-DPI + +Dark and light are both defined above with contrast checked against +`SurfaceBase`; `LoopTheme::HighContrast` is a third theme rather than a +toggle on either. Icon shapes in `StateIcon` are drawn by scene-graph/QML +primitives (no bitmap icon assets), so 100%/150%/200% scaling verification is +a rendering-path concern for whichever surface first consumes `StateIcon` — +tracked with the components above, not exercised by this issue's (non-visual) +token and mapping tests. + +## Adoption + +No Loop surface outside this design system consumes `resolveStateVisual()` +yet, because none of its consumers (`#193`, `#195`, `#196`, `#127`) have +landed. `CanvasPalette`'s existing severity-to-colour mapping +(`severityColor(OverlaySeverity)`) is the one place in the current codebase +that already does similar work; it is intentionally left as-is here (see the +`FocusRing`/`SeverityWarning` note above) and should be re-pointed at these +tokens when the canvas overlay work in `#196` picks it up, with its own +visual-regression coverage. + +Issue #191 (product-surface manifest) is closed; there is no open inherited +Widgets-dialog manifest for this document to extend. diff --git a/docs/generated/architecture-catalog.json b/docs/generated/architecture-catalog.json index dbf2774f..6b300048 100644 --- a/docs/generated/architecture-catalog.json +++ b/docs/generated/architecture-catalog.json @@ -410,6 +410,7 @@ "UnitTestsJbig2Decoder", "UnitTestsJobScheduler", "UnitTestsLifecycle", + "UnitTestsLoopStateVisual", "UnitTestsOcrCli", "UnitTestsOcrContract", "UnitTestsOcrPageGate", diff --git a/docs/generated/phase5-widgets-inventory.json b/docs/generated/phase5-widgets-inventory.json index 2004daf9..3d0377dc 100644 --- a/docs/generated/phase5-widgets-inventory.json +++ b/docs/generated/phase5-widgets-inventory.json @@ -86,6 +86,7 @@ "UnitTests/CMakeLists.txt", "UnitTests/CMakeLists.txt", "UnitTests/CMakeLists.txt", + "UnitTests/CMakeLists.txt", "loop-preflight/tools/CMakeLists.txt" ], "shell_ledger": "docs/loop-shell.json", @@ -445,6 +446,7 @@ "UnitTestsJbig2Decoder", "UnitTestsJobScheduler", "UnitTestsLifecycle", + "UnitTestsLoopStateVisual", "UnitTestsOcrPageGate", "UnitTestsOperationHistory", "UnitTestsOperationImpact", @@ -1778,6 +1780,46 @@ "widgets_paths": [], "consumers": [] }, + { + "id": "UnitTestsLoopStateVisual", + "kind": "executable", + "cmake": "UnitTests/CMakeLists.txt", + "profile_enabled": false, + "profile_condition": "qualification target excluded from the product-surface manifest", + "install_rule": false, + "installed_in_profile": false, + "build_only_in_profile": false, + "direct_links": [ + "LoopLibCore", + "Qt6::Core", + "Qt6::Gui", + "Qt6::Test" + ], + "direct_qt_modules": [ + "Core", + "Gui", + "Test" + ], + "transitive_targets": [ + "LoopLibCore" + ], + "transitive_qt_modules": [ + "Sql", + "Svg", + "Xml" + ], + "qt_modules": [ + "Core", + "Gui", + "Sql", + "Svg", + "Test", + "Xml" + ], + "widgets_linkage": "none", + "widgets_paths": [], + "consumers": [] + }, { "id": "UnitTestsOcrCli", "kind": "executable", @@ -3080,7 +3122,7 @@ } ], "counts": { - "targets": 70, + "targets": 71, "installed_in_profile": 4, "build_only_in_profile": 3, "widgets_surfaces": 4, diff --git a/scripts/budget_exhaustion/generate_corpus.py b/scripts/budget_exhaustion/generate_corpus.py index 3ad577a6..03cfc314 100644 --- a/scripts/budget_exhaustion/generate_corpus.py +++ b/scripts/budget_exhaustion/generate_corpus.py @@ -11,7 +11,7 @@ DEFAULT_OUTPUT = Path(__file__).resolve().parents[2] / "UnitTests" / "testdata" / "budget-exhaustion" -SCHEMA_KIND = "loupe-processing-budget-exhaustion-corpus" +SCHEMA_KIND = "loop-processing-budget-exhaustion-corpus" SCHEMA_VERSION = 2 diff --git a/scripts/ci/test_verify_phase5_widgets_contract.py b/scripts/ci/test_verify_phase5_widgets_contract.py index 2b700afa..8a268871 100644 --- a/scripts/ci/test_verify_phase5_widgets_contract.py +++ b/scripts/ci/test_verify_phase5_widgets_contract.py @@ -31,7 +31,7 @@ def setUpClass(cls): def test_current_evidence_is_valid_and_complete(self): self.assertEqual(validate_contract(ROOT, self.inventory, self.disposition), []) - self.assertEqual(self.inventory["counts"]["targets"], 70) + self.assertEqual(self.inventory["counts"]["targets"], 71) self.assertEqual(self.inventory["counts"]["widgets_surfaces"], 4) self.assertEqual(self.inventory["counts"]["ui_forms"], 2) self.assertEqual(len(self.inventory["plugin_ui"]), 0)