Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions LoopLibQuick/sources/canvaspalette.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@

#include "canvaspalette.h"

#include "looptokens.h"

namespace pdfquick
{

Expand All @@ -43,9 +45,8 @@ constexpr const char* TokenFocus = "#FDE68A";
constexpr const char* TokenDanger = "#FCA5A5";
constexpr const char* TokenSuccess = "#86EFAC";

/// docs/quick-design-tokens.json, focus.
constexpr float FocusOutlineWidthPx = 2.0f;
constexpr float FocusOutlineOffsetPx = 2.0f;
constexpr float FocusOutlineWidthPx = static_cast<float>(tokens::FocusOutlineWidthPx);
constexpr float FocusOutlineOffsetPx = static_cast<float>(tokens::FocusOutlineOffsetPx);

/// Severity stroke widths. These are the redundant encoding that keeps severity
/// legible without colour; see CanvasPalette's `must_not_depend_on_color_alone`
Expand Down
9 changes: 4 additions & 5 deletions LoopLibQuick/sources/canvastraceoverlay.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@

#include "canvastraceoverlay.h"

#include "looptokens.h"

#include <QFont>
#include <QFontMetrics>
#include <QPainter>
Expand All @@ -33,11 +35,8 @@ namespace pdfquick
namespace
{

/// docs/quick-design-tokens.json, typography.small_px.
constexpr int SmallTextPx = 12;

/// docs/quick-design-tokens.json, spacing.values_px.
constexpr int PanelPaddingPx = 8;
constexpr int SmallTextPx = tokens::TypeSmallPx;
constexpr int PanelPaddingPx = tokens::SpaceS;

QString formatMs(const QJsonValue& value)
{
Expand Down
64 changes: 36 additions & 28 deletions LoopLibQuick/sources/loopstatevisual.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,49 +28,65 @@
namespace pdfquick::tokens
{

QString stateAccessibleName(StateKind kind)
{
switch (kind)
{
case StateKind::Error:
return QStringLiteral("Error");
case StateKind::Warning:
return QStringLiteral("Warning");
case StateKind::Info:
return QStringLiteral("Info");
case StateKind::Incomplete:
return QStringLiteral("Incomplete");
case StateKind::NotChecked:
return QStringLiteral("Not checked");
case StateKind::Passed:
return QStringLiteral("Passed");
case StateKind::Waived:
return QStringLiteral("Waived");
}

return QStringLiteral("Not checked");
}

namespace
{

LoopStateVisual makeVisual(StateKind kind, ColorRole colorRole, StateIcon icon)
{
return { kind, colorRole, icon, stateAccessibleName(kind) };
}

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 };
return makeVisual(StateKind::Error, ColorRole::SeverityError, StateIcon::FilledCircle);
}
if (severity.compare(QLatin1String("warning"), Qt::CaseInsensitive) == 0)
{
return { StateKind::Warning, ColorRole::SeverityWarning, StateIcon::FilledTriangle };
return makeVisual(StateKind::Warning, ColorRole::SeverityWarning, StateIcon::FilledTriangle);
}
if (severity.compare(QLatin1String("info"), Qt::CaseInsensitive) == 0)
{
return { StateKind::Info, ColorRole::SeverityInfo, StateIcon::FilledSquare };
return makeVisual(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 };
return makeVisual(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 };
return makeVisual(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 };
return makeVisual(StateKind::Incomplete, ColorRole::StateIncomplete, StateIcon::Hatched);
}

} // namespace
Expand All @@ -81,18 +97,12 @@ LoopStateVisual resolveStateVisual(const pdf::PreflightFinding* finding,
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 };
return makeVisual(StateKind::Waived, ColorRole::SeverityWarning, StateIcon::BadgeOverlay);
}
}

Expand All @@ -106,9 +116,7 @@ LoopStateVisual resolveStateVisual(const pdf::PreflightFinding* finding,
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 };
return makeVisual(StateKind::NotChecked, ColorRole::StateNotChecked, StateIcon::Outline);
}

} // namespace pdfquick::tokens
55 changes: 12 additions & 43 deletions LoopLibQuick/sources/loopstatevisual.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,6 @@ struct PreflightDecision;
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,
Expand All @@ -55,55 +50,29 @@ enum class StateKind
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.
/// Non-colour shape. BadgeOverlay is drawn on top of the finding's severity treatment.
enum class StateIcon
{
FilledCircle, // Error
FilledTriangle, // Warning
FilledSquare, // Info
Hatched, // Incomplete
Outline, // Not checked
Checkmark, // Passed
BadgeOverlay // Waived
FilledCircle,
FilledTriangle,
FilledSquare,
Hatched,
Outline,
Checkmark,
BadgeOverlay
};

struct LoopStateVisual
{
StateKind kind = StateKind::NotChecked;
ColorRole colorRole = ColorRole::StateNotChecked;
StateIcon icon = StateIcon::Outline;
QString accessibleName;
};

/// 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 QString stateAccessibleName(StateKind kind);

/// Canonical finding/check presentation. Incomplete and active Waive never resolve as Passed.
LOOPLIBQUICK_EXPORT LoopStateVisual resolveStateVisual(const pdf::PreflightFinding* finding,
const pdf::PreflightCheckStatus* status,
const pdf::PreflightDecision* decision,
Expand Down
29 changes: 6 additions & 23 deletions LoopLibQuick/sources/looptokens.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,8 @@ 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.
// Dark values match docs/quick-design-tokens.json where a role existed there.
// FocusRing is violet, not the JSON `focus` colour CanvasPalette reuses for warnings.
constexpr const char* DarkSurfaceBase = "#111827";
constexpr const char* DarkSurfacePanel = "#1F2937";
constexpr const char* DarkSurfaceOverlay = "#374151";
Expand All @@ -57,7 +46,6 @@ 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";
Expand Down Expand Up @@ -152,11 +140,6 @@ QColor colorLight(ColorRole role)
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)
Expand All @@ -172,9 +155,12 @@ QColor colorHighContrast(ColorRole role)
return QColor(Qt::white);

case ColorRole::SeverityError:
case ColorRole::DestructiveAction:
return QColor(Qt::red);

// White-on-Qt::red is ~4.0:1; use the light fill so button text stays at 4.5:1.
case ColorRole::DestructiveAction:
return hex(LightDestructiveAction);

case ColorRole::SeverityWarning:
case ColorRole::FocusRing:
return QColor(Qt::yellow);
Expand All @@ -185,9 +171,6 @@ QColor colorHighContrast(ColorRole role)
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);
Expand Down
41 changes: 20 additions & 21 deletions LoopLibQuick/sources/looptokens.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,32 +31,36 @@
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.
// Spacing — 4px base grid. Mirrors docs/quick-design-tokens.json `spacing.values_px`.
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.
// Typography — mirrors docs/quick-design-tokens.json `typography`.
inline constexpr int TypeBodyPx = 14;
inline constexpr int TypeSmallPx = 12;
inline constexpr int TypeHeadingPx = 24;

// Focus geometry — mirrors docs/quick-design-tokens.json `focus`.
inline constexpr int FocusOutlineWidthPx = 2;
inline constexpr int FocusOutlineOffsetPx = 2;

// Density — mirrors docs/quick-design-tokens.json `density`.
inline constexpr int MinimumPointerTargetPx = 44;
inline constexpr int MinimumKeyboardTargetPx = 32;

/// `HighContrast` is a third theme, not a flag on Dark/Light.
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.
/// Semantic colour role. Call sites name a role, never a hex value.
enum class ColorRole
{
SurfaceBase,
Expand All @@ -71,25 +75,20 @@ enum class ColorRole
SeverityWarning,
SeverityInfo,

/// The "no findings" treatment. Distinct from `StateIncomplete` and
/// `StateNotChecked` by more than hue -- see resolveStateVisual().
/// Clean pass. Distinct from Incomplete and NotChecked by more than hue.
Success,

/// A check that did not run to completion (budget exceeded, skipped,
/// unsupported). NOT a severity: never resolves to the `Success` role.
/// Check did not complete. Never the Success role.
StateIncomplete,

/// No run exists yet for this revision. Never the `Success` role.
/// No run 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()).
/// Resolves `role` for `theme`. Hex literals live only in looptokens.cpp.
LOOPLIBQUICK_EXPORT QColor color(ColorRole role, LoopTheme theme);

} // namespace pdfquick::tokens
Expand Down
1 change: 1 addition & 0 deletions UnitTests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,7 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY AND LOOP_BUILD_QUICK_CANVAS)
${CMAKE_SOURCE_DIR}/LoopLibQuick/sources
${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}
)
target_compile_definitions(UnitTestsLoopStateVisual PRIVATE LoopLibQuick_EXPORTS)
target_link_libraries(UnitTestsLoopStateVisual PRIVATE LoopLibCore Qt6::Core Qt6::Gui Qt6::Test)

set_target_properties(UnitTestsLoopStateVisual PROPERTIES
Expand Down
Loading
Loading