feat: Add Storybook configuration and motion tokens implementation
- Introduced Storybook configuration files (`main.ts`, `preview.ts`, `tsconfig.json`) for Angular components. - Created motion tokens in `motion-tokens.ts` to define durations, easing functions, and transforms. - Developed a Storybook story for motion tokens showcasing their usage and reduced motion fallback. - Added SCSS variables for motion durations, easing, and transforms in `_motion.scss`. - Implemented accessibility smoke tests using Playwright and Axe for automated accessibility checks. - Created portable and sealed bundle structures with corresponding JSON files for evidence locker. - Added shell script for verifying notify kit determinism.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using StellaOps.Graph.Indexer.Schema;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Analytics;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
@@ -44,8 +44,8 @@ public sealed class MongoGraphSnapshotProvider : IGraphSnapshotProvider
|
||||
var tenant = snapshot.GetValue("tenant", string.Empty).AsString;
|
||||
var snapshotId = snapshot.GetValue("snapshot_id", string.Empty).AsString;
|
||||
var generatedAt = snapshot.TryGetValue("generated_at", out var generated)
|
||||
&& generated.TryToUniversalTime(out var dt)
|
||||
? dt
|
||||
&& generated.BsonType == BsonType.DateTime
|
||||
? DateTime.SpecifyKind(generated.ToUniversalTime(), DateTimeKind.Utc)
|
||||
: DateTimeOffset.UtcNow;
|
||||
|
||||
var nodes = snapshot.TryGetValue("nodes", out var nodesValue) && nodesValue is BsonArray nodesArray
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Ingestion.Inspector;
|
||||
|
||||
public sealed class GraphInspectorSnapshot
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "graph.inspect.v1";
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
public string ArtifactDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sbomDigest")]
|
||||
public string SbomDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("collectedAt")]
|
||||
public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UnixEpoch;
|
||||
|
||||
[JsonPropertyName("eventOffset")]
|
||||
public long EventOffset { get; init; }
|
||||
|
||||
[JsonPropertyName("components")]
|
||||
public IReadOnlyList<GraphInspectorComponent> Components { get; init; } = Array.Empty<GraphInspectorComponent>();
|
||||
|
||||
[JsonPropertyName("links")]
|
||||
public GraphInspectorLinks Links { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class GraphInspectorComponent
|
||||
{
|
||||
[JsonPropertyName("purl")]
|
||||
public string Purl { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("scopes")]
|
||||
public IReadOnlyList<string> Scopes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("relationships")]
|
||||
public IReadOnlyList<GraphInspectorRelationship> Relationships { get; init; } = Array.Empty<GraphInspectorRelationship>();
|
||||
|
||||
[JsonPropertyName("advisories")]
|
||||
public IReadOnlyList<GraphInspectorAdvisoryObservation> Advisories { get; init; } = Array.Empty<GraphInspectorAdvisoryObservation>();
|
||||
|
||||
[JsonPropertyName("vexStatements")]
|
||||
public IReadOnlyList<GraphInspectorVexStatement> VexStatements { get; init; } = Array.Empty<GraphInspectorVexStatement>();
|
||||
|
||||
[JsonPropertyName("provenance")]
|
||||
public GraphInspectorProvenance? Provenance { get; init; }
|
||||
}
|
||||
|
||||
public sealed class GraphInspectorRelationship
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("targetPurl")]
|
||||
public string TargetPurl { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public string? Scope { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceHash")]
|
||||
public string EvidenceHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("provenance")]
|
||||
public GraphInspectorProvenance? Provenance { get; init; }
|
||||
}
|
||||
|
||||
public sealed class GraphInspectorAdvisoryObservation
|
||||
{
|
||||
[JsonPropertyName("advisoryId")]
|
||||
public string AdvisoryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("cvss")]
|
||||
public GraphInspectorCvss? Cvss { get; init; }
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
[JsonPropertyName("justificationSummary")]
|
||||
public string? JustificationSummary { get; init; }
|
||||
|
||||
[JsonPropertyName("linksetDigest")]
|
||||
public string? LinksetDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceHash")]
|
||||
public string EvidenceHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("modifiedAt")]
|
||||
public DateTimeOffset ModifiedAt { get; init; } = DateTimeOffset.UnixEpoch;
|
||||
|
||||
[JsonPropertyName("provenance")]
|
||||
public GraphInspectorProvenance? Provenance { get; init; }
|
||||
}
|
||||
|
||||
public sealed class GraphInspectorCvss
|
||||
{
|
||||
[JsonPropertyName("vector")]
|
||||
public string? Vector { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
}
|
||||
|
||||
public sealed class GraphInspectorVexStatement
|
||||
{
|
||||
[JsonPropertyName("statementId")]
|
||||
public string StatementId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public string Justification { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("impactStatement")]
|
||||
public string? ImpactStatement { get; init; }
|
||||
|
||||
[JsonPropertyName("knownExploited")]
|
||||
public bool? KnownExploited { get; init; }
|
||||
|
||||
[JsonPropertyName("issuedAt")]
|
||||
public DateTimeOffset IssuedAt { get; init; } = DateTimeOffset.UnixEpoch;
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceHash")]
|
||||
public string EvidenceHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("provenance")]
|
||||
public GraphInspectorProvenance? Provenance { get; init; }
|
||||
}
|
||||
|
||||
public sealed class GraphInspectorLinks
|
||||
{
|
||||
[JsonPropertyName("sbomObservationEventId")]
|
||||
public string? SbomObservationEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("linksetDigest")]
|
||||
public string? LinksetDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed class GraphInspectorProvenance
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("collectedAt")]
|
||||
public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UnixEpoch;
|
||||
|
||||
[JsonPropertyName("eventOffset")]
|
||||
public long EventOffset { get; init; }
|
||||
|
||||
[JsonPropertyName("linksetDigest")]
|
||||
public string? LinksetDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceHash")]
|
||||
public string? EvidenceHash { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using StellaOps.Graph.Indexer.Schema;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Ingestion.Inspector;
|
||||
|
||||
public sealed class GraphInspectorTransformer
|
||||
{
|
||||
private const string ArtifactKind = "artifact";
|
||||
private const string ComponentKind = "component";
|
||||
private const string AdvisoryKind = "advisory";
|
||||
private const string VexKind = "vex_statement";
|
||||
private const string ContainsEdgeKind = "CONTAINS";
|
||||
private const string DependsOnEdgeKind = "DEPENDS_ON";
|
||||
private const string ProvidesEdgeKind = "PROVIDES";
|
||||
private const string ObservedRuntimeEdgeKind = "OBSERVED_RUNTIME";
|
||||
private const string AffectedByEdgeKind = "AFFECTED_BY";
|
||||
private const string VexExemptsEdgeKind = "VEX_EXEMPTS";
|
||||
private const string DefaultSourceType = "inventory";
|
||||
private const string DefaultSource = "graph.inspect.v1";
|
||||
|
||||
public GraphBuildBatch Transform(GraphInspectorSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
var nodes = new List<JsonObject>();
|
||||
var edges = new List<JsonObject>();
|
||||
|
||||
var componentNodes = new Dictionary<string, JsonObject>(StringComparer.OrdinalIgnoreCase);
|
||||
var advisoryNodes = new Dictionary<(string Tenant, string Source, string AdvisoryId, string ContentHash), JsonObject>();
|
||||
var vexNodes = new Dictionary<(string Tenant, string StatementId, string Source, string EvidenceHash), JsonObject>();
|
||||
|
||||
var artifactNode = CreateArtifactNode(snapshot);
|
||||
nodes.Add(artifactNode);
|
||||
|
||||
foreach (var component in snapshot.Components)
|
||||
{
|
||||
var componentNode = GetOrCreateComponentNode(snapshot, componentNodes, component, component.Provenance);
|
||||
nodes.Add(componentNode);
|
||||
|
||||
foreach (var relationship in component.Relationships ?? Array.Empty<GraphInspectorRelationship>())
|
||||
{
|
||||
var targetNode = GetOrCreateComponentNode(
|
||||
snapshot,
|
||||
componentNodes,
|
||||
new GraphInspectorComponent
|
||||
{
|
||||
Purl = relationship.TargetPurl,
|
||||
Version = null,
|
||||
Scopes = Array.Empty<string>(),
|
||||
Relationships = Array.Empty<GraphInspectorRelationship>(),
|
||||
Advisories = Array.Empty<GraphInspectorAdvisoryObservation>(),
|
||||
VexStatements = Array.Empty<GraphInspectorVexStatement>(),
|
||||
Provenance = relationship.Provenance
|
||||
},
|
||||
relationship.Provenance);
|
||||
|
||||
var edge = CreateRelationshipEdge(snapshot, componentNode, targetNode, relationship);
|
||||
edges.Add(edge);
|
||||
}
|
||||
|
||||
foreach (var advisory in component.Advisories ?? Array.Empty<GraphInspectorAdvisoryObservation>())
|
||||
{
|
||||
var advisoryNode = GetOrCreateAdvisoryNode(snapshot, advisoryNodes, advisory);
|
||||
if (!nodes.Contains(advisoryNode))
|
||||
{
|
||||
nodes.Add(advisoryNode);
|
||||
}
|
||||
|
||||
var edge = CreateAffectedByEdge(snapshot, componentNode, advisoryNode, advisory);
|
||||
edges.Add(edge);
|
||||
}
|
||||
|
||||
foreach (var vex in component.VexStatements ?? Array.Empty<GraphInspectorVexStatement>())
|
||||
{
|
||||
var vexNode = GetOrCreateVexNode(snapshot, vexNodes, vex);
|
||||
if (!nodes.Contains(vexNode))
|
||||
{
|
||||
nodes.Add(vexNode);
|
||||
}
|
||||
|
||||
var edge = CreateVexExemptsEdge(snapshot, componentNode, vexNode, vex);
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
var orderedNodes = nodes
|
||||
.Distinct(JsonNodeEqualityComparer.Instance)
|
||||
.Select(n => (JsonObject)n)
|
||||
.OrderBy(n => n["kind"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ThenBy(n => n["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedEdges = edges
|
||||
.Distinct(JsonNodeEqualityComparer.Instance)
|
||||
.Select(e => (JsonObject)e)
|
||||
.OrderBy(e => e["kind"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ThenBy(e => e["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new GraphBuildBatch(orderedNodes, orderedEdges);
|
||||
}
|
||||
|
||||
private JsonObject CreateArtifactNode(GraphInspectorSnapshot snapshot)
|
||||
{
|
||||
var attributes = new JsonObject
|
||||
{
|
||||
["artifact_digest"] = snapshot.ArtifactDigest,
|
||||
["sbom_digest"] = snapshot.SbomDigest
|
||||
};
|
||||
|
||||
var provenance = CreateProvenance(snapshot.SchemaVersion, snapshot.CollectedAt, snapshot.SbomDigest, snapshot.EventOffset);
|
||||
|
||||
return GraphDocumentFactory.CreateNode(new GraphNodeSpec(
|
||||
Tenant: snapshot.Tenant,
|
||||
Kind: ArtifactKind,
|
||||
CanonicalKey: new Dictionary<string, string>
|
||||
{
|
||||
["tenant"] = snapshot.Tenant,
|
||||
["artifact_digest"] = snapshot.ArtifactDigest,
|
||||
["sbom_digest"] = snapshot.SbomDigest
|
||||
},
|
||||
Attributes: attributes,
|
||||
Provenance: provenance,
|
||||
ValidFrom: snapshot.CollectedAt,
|
||||
ValidTo: null));
|
||||
}
|
||||
|
||||
private JsonObject GetOrCreateComponentNode(
|
||||
GraphInspectorSnapshot snapshot,
|
||||
IDictionary<string, JsonObject> cache,
|
||||
GraphInspectorComponent component,
|
||||
GraphInspectorProvenance? provenanceOverride)
|
||||
{
|
||||
var key = $"{component.Purl}|{DefaultSourceType}";
|
||||
if (cache.TryGetValue(key, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var attributes = new JsonObject
|
||||
{
|
||||
["purl"] = component.Purl,
|
||||
["version"] = component.Version ?? string.Empty,
|
||||
["scope"] = string.Empty,
|
||||
["license_spdx"] = string.Empty,
|
||||
["usage"] = string.Empty
|
||||
};
|
||||
|
||||
if (component.Scopes?.Any() == true)
|
||||
{
|
||||
var scopesArray = new JsonArray();
|
||||
foreach (var scope in component.Scopes.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct(StringComparer.Ordinal).OrderBy(s => s, StringComparer.Ordinal))
|
||||
{
|
||||
scopesArray.Add(scope);
|
||||
}
|
||||
|
||||
attributes["scopes"] = scopesArray;
|
||||
}
|
||||
|
||||
var provenance = ResolveProvenance(snapshot, provenanceOverride);
|
||||
|
||||
var node = GraphDocumentFactory.CreateNode(new GraphNodeSpec(
|
||||
Tenant: snapshot.Tenant,
|
||||
Kind: ComponentKind,
|
||||
CanonicalKey: new Dictionary<string, string>
|
||||
{
|
||||
["tenant"] = snapshot.Tenant,
|
||||
["purl"] = component.Purl,
|
||||
["source_type"] = DefaultSourceType
|
||||
},
|
||||
Attributes: attributes,
|
||||
Provenance: provenance,
|
||||
ValidFrom: provenance.CollectedAt,
|
||||
ValidTo: null));
|
||||
|
||||
cache[key] = node;
|
||||
return node;
|
||||
}
|
||||
|
||||
private JsonObject CreateRelationshipEdge(
|
||||
GraphInspectorSnapshot snapshot,
|
||||
JsonObject sourceNode,
|
||||
JsonObject targetNode,
|
||||
GraphInspectorRelationship relationship)
|
||||
{
|
||||
var kind = relationship.Type?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"contains" => ContainsEdgeKind,
|
||||
"depends_on" => DependsOnEdgeKind,
|
||||
"provides" => ProvidesEdgeKind,
|
||||
"runtime_observed" => ObservedRuntimeEdgeKind,
|
||||
_ => DependsOnEdgeKind
|
||||
};
|
||||
|
||||
var attributes = new JsonObject
|
||||
{
|
||||
["scope"] = relationship.Scope ?? string.Empty,
|
||||
["evidence_digest"] = relationship.EvidenceHash,
|
||||
["source"] = relationship.Source ?? string.Empty
|
||||
};
|
||||
|
||||
var provenance = ResolveProvenance(snapshot, relationship.Provenance);
|
||||
|
||||
return GraphDocumentFactory.CreateEdge(new GraphEdgeSpec(
|
||||
Tenant: snapshot.Tenant,
|
||||
Kind: kind,
|
||||
CanonicalKey: new Dictionary<string, string>
|
||||
{
|
||||
["tenant"] = snapshot.Tenant,
|
||||
["source_node_id"] = sourceNode["id"]!.GetValue<string>(),
|
||||
["target_node_id"] = targetNode["id"]!.GetValue<string>(),
|
||||
["sbom_digest"] = snapshot.SbomDigest
|
||||
},
|
||||
Attributes: attributes,
|
||||
Provenance: provenance,
|
||||
ValidFrom: provenance.CollectedAt,
|
||||
ValidTo: null));
|
||||
}
|
||||
|
||||
private JsonObject GetOrCreateAdvisoryNode(
|
||||
GraphInspectorSnapshot snapshot,
|
||||
IDictionary<(string, string, string, string), JsonObject> cache,
|
||||
GraphInspectorAdvisoryObservation advisory)
|
||||
{
|
||||
var contentHash = advisory.EvidenceHash ?? string.Empty;
|
||||
var key = (snapshot.Tenant, advisory.Source, advisory.AdvisoryId, contentHash);
|
||||
if (cache.TryGetValue(key, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var attributes = new JsonObject
|
||||
{
|
||||
["advisory_source"] = advisory.Source,
|
||||
["advisory_id"] = advisory.AdvisoryId,
|
||||
["severity"] = advisory.Severity ?? string.Empty,
|
||||
["published_at"] = GraphTimestamp.Format(advisory.ModifiedAt),
|
||||
["content_hash"] = contentHash,
|
||||
["linkset_digest"] = advisory.LinksetDigest ?? string.Empty
|
||||
};
|
||||
|
||||
var canonicalKey = new Dictionary<string, string>
|
||||
{
|
||||
["tenant"] = snapshot.Tenant,
|
||||
["advisory_source"] = advisory.Source,
|
||||
["advisory_id"] = advisory.AdvisoryId,
|
||||
["content_hash"] = contentHash
|
||||
};
|
||||
|
||||
var provenance = ResolveProvenance(snapshot, advisory.Provenance);
|
||||
|
||||
var node = GraphDocumentFactory.CreateNode(new GraphNodeSpec(
|
||||
Tenant: snapshot.Tenant,
|
||||
Kind: AdvisoryKind,
|
||||
CanonicalKey: canonicalKey,
|
||||
Attributes: attributes,
|
||||
Provenance: provenance,
|
||||
ValidFrom: advisory.ModifiedAt,
|
||||
ValidTo: null));
|
||||
|
||||
cache[key] = node;
|
||||
return node;
|
||||
}
|
||||
|
||||
private JsonObject CreateAffectedByEdge(
|
||||
GraphInspectorSnapshot snapshot,
|
||||
JsonObject componentNode,
|
||||
JsonObject advisoryNode,
|
||||
GraphInspectorAdvisoryObservation advisory)
|
||||
{
|
||||
var attributes = new JsonObject
|
||||
{
|
||||
["evidence_digest"] = advisory.EvidenceHash,
|
||||
["matched_versions"] = new JsonArray(),
|
||||
["cvss"] = advisory.Cvss is null ? null : new JsonObject
|
||||
{
|
||||
["vector"] = advisory.Cvss.Vector,
|
||||
["score"] = advisory.Cvss.Score
|
||||
},
|
||||
["justification"] = advisory.Justification ?? string.Empty,
|
||||
["justification_summary"] = advisory.JustificationSummary ?? string.Empty,
|
||||
["status"] = advisory.Status
|
||||
};
|
||||
|
||||
var canonicalKey = new Dictionary<string, string>
|
||||
{
|
||||
["tenant"] = snapshot.Tenant,
|
||||
["component_node_id"] = componentNode["id"]!.GetValue<string>(),
|
||||
["advisory_node_id"] = advisoryNode["id"]!.GetValue<string>(),
|
||||
["linkset_digest"] = advisory.LinksetDigest ?? string.Empty
|
||||
};
|
||||
|
||||
var provenance = ResolveProvenance(snapshot, advisory.Provenance);
|
||||
|
||||
return GraphDocumentFactory.CreateEdge(new GraphEdgeSpec(
|
||||
Tenant: snapshot.Tenant,
|
||||
Kind: AffectedByEdgeKind,
|
||||
CanonicalKey: canonicalKey,
|
||||
Attributes: attributes,
|
||||
Provenance: provenance,
|
||||
ValidFrom: advisory.ModifiedAt,
|
||||
ValidTo: null));
|
||||
}
|
||||
|
||||
private JsonObject GetOrCreateVexNode(
|
||||
GraphInspectorSnapshot snapshot,
|
||||
IDictionary<(string Tenant, string StatementId, string Source, string EvidenceHash), JsonObject> cache,
|
||||
GraphInspectorVexStatement vex)
|
||||
{
|
||||
var key = (snapshot.Tenant, vex.StatementId, vex.Source, vex.EvidenceHash);
|
||||
if (cache.TryGetValue(key, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var attributes = new JsonObject
|
||||
{
|
||||
["status"] = vex.Status,
|
||||
["statement_id"] = vex.StatementId,
|
||||
["justification"] = vex.Justification,
|
||||
["impact_statement"] = vex.ImpactStatement ?? string.Empty,
|
||||
["known_exploited"] = vex.KnownExploited ?? false,
|
||||
["content_hash"] = vex.EvidenceHash
|
||||
};
|
||||
|
||||
var canonicalKey = new Dictionary<string, string>
|
||||
{
|
||||
["tenant"] = snapshot.Tenant,
|
||||
["vex_source"] = vex.Source,
|
||||
["statement_id"] = vex.StatementId,
|
||||
["content_hash"] = vex.EvidenceHash
|
||||
};
|
||||
|
||||
var provenance = ResolveProvenance(snapshot, vex.Provenance);
|
||||
|
||||
var node = GraphDocumentFactory.CreateNode(new GraphNodeSpec(
|
||||
Tenant: snapshot.Tenant,
|
||||
Kind: VexKind,
|
||||
CanonicalKey: canonicalKey,
|
||||
Attributes: attributes,
|
||||
Provenance: provenance,
|
||||
ValidFrom: vex.IssuedAt,
|
||||
ValidTo: vex.ExpiresAt));
|
||||
|
||||
cache[key] = node;
|
||||
return node;
|
||||
}
|
||||
|
||||
private JsonObject CreateVexExemptsEdge(
|
||||
GraphInspectorSnapshot snapshot,
|
||||
JsonObject componentNode,
|
||||
JsonObject vexNode,
|
||||
GraphInspectorVexStatement vex)
|
||||
{
|
||||
var attributes = new JsonObject
|
||||
{
|
||||
["status"] = vex.Status,
|
||||
["justification"] = vex.Justification,
|
||||
["impact_statement"] = vex.ImpactStatement ?? string.Empty,
|
||||
["evidence_digest"] = vex.EvidenceHash
|
||||
};
|
||||
|
||||
var canonicalKey = new Dictionary<string, string>
|
||||
{
|
||||
["tenant"] = snapshot.Tenant,
|
||||
["component_node_id"] = componentNode["id"]!.GetValue<string>(),
|
||||
["vex_node_id"] = vexNode["id"]!.GetValue<string>(),
|
||||
["statement_hash"] = vex.EvidenceHash
|
||||
};
|
||||
|
||||
var provenance = ResolveProvenance(snapshot, vex.Provenance);
|
||||
|
||||
return GraphDocumentFactory.CreateEdge(new GraphEdgeSpec(
|
||||
Tenant: snapshot.Tenant,
|
||||
Kind: VexExemptsEdgeKind,
|
||||
CanonicalKey: canonicalKey,
|
||||
Attributes: attributes,
|
||||
Provenance: provenance,
|
||||
ValidFrom: vex.IssuedAt,
|
||||
ValidTo: vex.ExpiresAt));
|
||||
}
|
||||
|
||||
private static GraphProvenanceSpec ResolveProvenance(GraphInspectorSnapshot snapshot, GraphInspectorProvenance? overrideProvenance)
|
||||
{
|
||||
if (overrideProvenance is not null)
|
||||
{
|
||||
return new GraphProvenanceSpec(
|
||||
Source: string.IsNullOrWhiteSpace(overrideProvenance.Source) ? DefaultSource : overrideProvenance.Source,
|
||||
CollectedAt: overrideProvenance.CollectedAt == DateTimeOffset.UnixEpoch ? snapshot.CollectedAt : overrideProvenance.CollectedAt,
|
||||
SbomDigest: snapshot.SbomDigest,
|
||||
EventOffset: overrideProvenance.EventOffset == 0 ? snapshot.EventOffset : overrideProvenance.EventOffset);
|
||||
}
|
||||
|
||||
return new GraphProvenanceSpec(
|
||||
Source: DefaultSource,
|
||||
CollectedAt: snapshot.CollectedAt,
|
||||
SbomDigest: snapshot.SbomDigest,
|
||||
EventOffset: snapshot.EventOffset);
|
||||
}
|
||||
|
||||
private static GraphProvenanceSpec CreateProvenance(string? source, DateTimeOffset collectedAt, string? sbomDigest, long? eventOffset)
|
||||
{
|
||||
return new GraphProvenanceSpec(
|
||||
Source: string.IsNullOrWhiteSpace(source) ? DefaultSource : source!,
|
||||
CollectedAt: collectedAt,
|
||||
SbomDigest: sbomDigest,
|
||||
EventOffset: eventOffset);
|
||||
}
|
||||
|
||||
private sealed class JsonNodeEqualityComparer : IEqualityComparer<JsonNode>
|
||||
{
|
||||
public static readonly JsonNodeEqualityComparer Instance = new();
|
||||
|
||||
public bool Equals(JsonNode? x, JsonNode? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return x.ToJsonString() == y.ToJsonString();
|
||||
}
|
||||
|
||||
public int GetHashCode(JsonNode obj) => obj.ToJsonString().GetHashCode(StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using StellaOps.Graph.Indexer.Ingestion.Inspector;
|
||||
using StellaOps.Graph.Indexer.Schema;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class GraphInspectorTransformerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Transform_BuildsNodesAndEdges_FromInspectorSnapshot()
|
||||
{
|
||||
var snapshot = new GraphInspectorSnapshot
|
||||
{
|
||||
Tenant = "acme-dev",
|
||||
ArtifactDigest = "sha256:artifact",
|
||||
SbomDigest = "sha256:sbom",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-12-04T15:30:00Z"),
|
||||
EventOffset = 5123,
|
||||
Components = new[]
|
||||
{
|
||||
new GraphInspectorComponent
|
||||
{
|
||||
Purl = "pkg:maven/org.example/foo@1.2.3",
|
||||
Scopes = new[] {"runtime"},
|
||||
Relationships = new[]
|
||||
{
|
||||
new GraphInspectorRelationship
|
||||
{
|
||||
Type = "depends_on",
|
||||
TargetPurl = "pkg:npm/lodash@4.17.21",
|
||||
Scope = "runtime",
|
||||
EvidenceHash = "e1",
|
||||
Source = "concelier.linkset.v1",
|
||||
Provenance = new GraphInspectorProvenance
|
||||
{
|
||||
Source = "concelier.linkset.v1",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-12-04T15:29:00Z"),
|
||||
EventOffset = 6000,
|
||||
EvidenceHash = "e1"
|
||||
}
|
||||
}
|
||||
},
|
||||
Advisories = new[]
|
||||
{
|
||||
new GraphInspectorAdvisoryObservation
|
||||
{
|
||||
AdvisoryId = "CVE-2024-1111",
|
||||
Source = "ghsa",
|
||||
Status = "affected",
|
||||
Severity = "HIGH",
|
||||
EvidenceHash = "a1",
|
||||
LinksetDigest = "abcdef",
|
||||
ModifiedAt = DateTimeOffset.Parse("2025-11-30T12:00:00Z"),
|
||||
Provenance = new GraphInspectorProvenance
|
||||
{
|
||||
Source = "concelier.linkset.v1",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-11-30T11:55:00Z"),
|
||||
EventOffset = 4421,
|
||||
EvidenceHash = "a1"
|
||||
}
|
||||
}
|
||||
},
|
||||
VexStatements = new[]
|
||||
{
|
||||
new GraphInspectorVexStatement
|
||||
{
|
||||
StatementId = "VEX-2025-0001",
|
||||
Source = "excitor.vex.v1",
|
||||
Status = "not_affected",
|
||||
Justification = "component_not_present",
|
||||
EvidenceHash = "v1",
|
||||
IssuedAt = DateTimeOffset.Parse("2025-12-01T08:00:00Z"),
|
||||
ExpiresAt = DateTimeOffset.Parse("2026-12-01T00:00:00Z"),
|
||||
Provenance = new GraphInspectorProvenance
|
||||
{
|
||||
Source = "excitor.overlay.v1",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-12-01T08:00:00Z"),
|
||||
EventOffset = 171,
|
||||
EvidenceHash = "v1"
|
||||
}
|
||||
}
|
||||
},
|
||||
Provenance = new GraphInspectorProvenance
|
||||
{
|
||||
Source = "concelier.linkset.v1",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-12-04T15:29:00Z"),
|
||||
EventOffset = 5123,
|
||||
EvidenceHash = "c1"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var transformer = new GraphInspectorTransformer();
|
||||
|
||||
var batch = transformer.Transform(snapshot);
|
||||
|
||||
// Nodes: artifact + source component + target component + advisory + vex
|
||||
Assert.Equal(5, batch.Nodes.Length);
|
||||
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "artifact");
|
||||
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "component" && n["canonical_key"]!["purl"]!.GetValue<string>() == "pkg:maven/org.example/foo@1.2.3");
|
||||
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "component" && n["canonical_key"]!["purl"]!.GetValue<string>() == "pkg:npm/lodash@4.17.21");
|
||||
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "advisory");
|
||||
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "vex_statement");
|
||||
|
||||
// Edges: depends_on, affected_by, vex_exempts
|
||||
Assert.Contains(batch.Edges, e => e["kind"]!.GetValue<string>() == "DEPENDS_ON");
|
||||
Assert.Contains(batch.Edges, e => e["kind"]!.GetValue<string>() == "AFFECTED_BY");
|
||||
Assert.Contains(batch.Edges, e => e["kind"]!.GetValue<string>() == "VEX_EXEMPTS");
|
||||
|
||||
// Provenance should carry sbom digest and event offset from snapshot/provenance overrides.
|
||||
var dependsOn = batch.Edges.Single(e => e["kind"]!.GetValue<string>() == "DEPENDS_ON");
|
||||
Assert.Equal("sha256:sbom", dependsOn["provenance"]!["sbom_digest"]!.GetValue<string>());
|
||||
Assert.Equal(6000, dependsOn["provenance"]!["event_offset"]!.GetValue<long>());
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,4 @@
|
||||
| NOTIFY-RISK-66-001 | DONE (2025-11-24) | Notifications Service Guild · Risk Engine Guild | Added risk-events endpoint + templates/rules for severity change notifications. |
|
||||
| NOTIFY-RISK-67-001 | DONE (2025-11-24) | Notifications Service Guild · Policy Guild | Added routing/templates for risk profile publish/deprecate/threshold change. |
|
||||
| NOTIFY-RISK-68-001 | DONE (2025-11-24) | Notifications Service Guild | Default routing seeds with throttles/locales for risk alerts. |
|
||||
| NOTIFY-GAPS-171-014 | TODO | Notifications Service Guild | NR1–NR10 scoped (`docs/product-advisories/31-Nov-2025 FINDINGS.md`, `docs/notifications/gaps-nr1-nr10.md`); implement schema catalog + DSSE, quotas/backpressure, retries/idempotency, security, redaction, observability, offline kit, and simulation evidence. |
|
||||
| NOTIFY-GAPS-171-014 | DOING (2025-12-04) | Notifications Service Guild | NR1–NR10 scoped; schemas/catalog/fixtures/offline kit scaffolded; fill BLAKE3 digests, DSSE signatures, and tests next. |
|
||||
|
||||
19
src/Web/StellaOps.Web/.storybook/main.ts
Normal file
19
src/Web/StellaOps.Web/.storybook/main.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { StorybookConfig } from '@storybook/angular';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/stories/**/*.stories.@(ts|mdx)'],
|
||||
addons: [
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-interactions',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/angular',
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: 'tag',
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
53
src/Web/StellaOps.Web/.storybook/preview.ts
Normal file
53
src/Web/StellaOps.Web/.storybook/preview.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Preview } from '@storybook/angular';
|
||||
import '../src/styles.scss';
|
||||
|
||||
export const globalTypes = {
|
||||
reduceMotion: {
|
||||
name: 'Reduced Motion',
|
||||
description: 'Toggle reduced-motion mode for motion tokens',
|
||||
defaultValue: false,
|
||||
toolbar: {
|
||||
icon: 'contrast',
|
||||
items: [
|
||||
{ value: false, title: 'Motion enabled' },
|
||||
{ value: true, title: 'Reduce motion' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
a11y: {
|
||||
// Keep enabled; violations surface in the Storybook panel
|
||||
disable: false,
|
||||
},
|
||||
backgrounds: {
|
||||
default: 'light',
|
||||
values: [
|
||||
{ name: 'light', value: '#f6f8fb' },
|
||||
{ name: 'dark', value: '#0f172a' },
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(story, context) => {
|
||||
const root = document.documentElement;
|
||||
if (context.globals.reduceMotion) {
|
||||
root.dataset.reduceMotion = '1';
|
||||
} else {
|
||||
root.dataset.reduceMotion = '0';
|
||||
}
|
||||
return story();
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
11
src/Web/StellaOps.Web/.storybook/tsconfig.json
Normal file
11
src/Web/StellaOps.Web/.storybook/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["storybook__angular", "node"]
|
||||
},
|
||||
"include": [
|
||||
"../src/**/*.ts",
|
||||
"../.storybook/**/*.ts",
|
||||
"../src/**/*.stories.ts"
|
||||
]
|
||||
}
|
||||
@@ -10,3 +10,4 @@
|
||||
| WEB-TEN-47-CONTRACT | DONE (2025-12-01) | Gateway tenant auth/ABAC contract doc v1.0 published (`docs/api/gateway/tenant-auth.md`). |
|
||||
| WEB-VULN-29-LEDGER-DOC | DONE (2025-12-01) | Findings Ledger proxy contract doc v1.0 with idempotency + retries (`docs/api/gateway/findings-ledger-proxy.md`). |
|
||||
| WEB-RISK-68-NOTIFY-DOC | DONE (2025-12-01) | Notifications severity transition event schema v1.0 published (`docs/api/gateway/notifications-severity.md`). |
|
||||
| UI-MICRO-GAPS-0209-011 | DOING (2025-12-04) | Motion token catalog + Storybook/Playwright a11y harness added; remaining work: component mapping, perf budgets, deterministic snapshots. |
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
"test:e2e": "playwright test",
|
||||
"serve:test": "ng serve --configuration development --port 4400 --host 127.0.0.1",
|
||||
"verify:chromium": "node ./scripts/verify-chromium.js",
|
||||
"ci:install": "npm ci --prefer-offline --no-audit --no-fund"
|
||||
"ci:install": "npm ci --prefer-offline --no-audit --no-fund",
|
||||
"storybook": "storybook dev -p 4600",
|
||||
"storybook:build": "storybook build",
|
||||
"test:a11y": "FAIL_ON_A11Y=0 playwright test tests/e2e/a11y-smoke.spec.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11.0",
|
||||
@@ -36,7 +39,16 @@
|
||||
"@angular-devkit/build-angular": "^17.3.17",
|
||||
"@angular/cli": "^17.3.17",
|
||||
"@angular/compiler-cli": "^17.3.0",
|
||||
"@axe-core/playwright": "4.8.4",
|
||||
"@playwright/test": "^1.47.2",
|
||||
"@storybook/addon-a11y": "8.1.0",
|
||||
"@storybook/addon-essentials": "8.1.0",
|
||||
"@storybook/addon-interactions": "8.1.0",
|
||||
"@storybook/angular": "8.1.0",
|
||||
"@storybook/angular-renderer": "8.1.0",
|
||||
"@storybook/test": "8.1.0",
|
||||
"@storybook/testing-library": "0.2.2",
|
||||
"storybook": "8.1.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.1.0",
|
||||
"karma": "~6.4.0",
|
||||
|
||||
39
src/Web/StellaOps.Web/src/app/styles/motion-tokens.ts
Normal file
39
src/Web/StellaOps.Web/src/app/styles/motion-tokens.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type MotionToken =
|
||||
| 'durationXs'
|
||||
| 'durationSm'
|
||||
| 'durationMd'
|
||||
| 'durationLg'
|
||||
| 'durationXl'
|
||||
| 'easeStandard'
|
||||
| 'easeEntrance'
|
||||
| 'easeExit'
|
||||
| 'easeBounce'
|
||||
| 'translateSm'
|
||||
| 'translateMd'
|
||||
| 'translateLg'
|
||||
| 'scaleSm'
|
||||
| 'scaleMd';
|
||||
|
||||
export const motionTokens: Record<MotionToken, string> = {
|
||||
durationXs: 'var(--motion-duration-xs)',
|
||||
durationSm: 'var(--motion-duration-sm)',
|
||||
durationMd: 'var(--motion-duration-md)',
|
||||
durationLg: 'var(--motion-duration-lg)',
|
||||
durationXl: 'var(--motion-duration-xl)',
|
||||
easeStandard: 'var(--motion-ease-standard)',
|
||||
easeEntrance: 'var(--motion-ease-entrance)',
|
||||
easeExit: 'var(--motion-ease-exit)',
|
||||
easeBounce: 'var(--motion-ease-bounce)',
|
||||
translateSm: 'var(--motion-translate-sm)',
|
||||
translateMd: 'var(--motion-translate-md)',
|
||||
translateLg: 'var(--motion-translate-lg)',
|
||||
scaleSm: 'var(--motion-scale-sm)',
|
||||
scaleMd: 'var(--motion-scale-md)',
|
||||
};
|
||||
|
||||
export function reduceMotionEnabled(): boolean {
|
||||
return (
|
||||
document.documentElement.dataset.reduceMotion === '1' ||
|
||||
document.documentElement.dataset.reduceMotion === 'true'
|
||||
);
|
||||
}
|
||||
103
src/Web/StellaOps.Web/src/stories/motion-tokens.stories.ts
Normal file
103
src/Web/StellaOps.Web/src/stories/motion-tokens.stories.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
|
||||
type CardArgs = {
|
||||
title: string;
|
||||
description: string;
|
||||
className: string;
|
||||
};
|
||||
|
||||
const meta: Meta<CardArgs> = {
|
||||
title: 'Design Tokens/Motion',
|
||||
args: {
|
||||
title: 'Motion tokens',
|
||||
description: 'Durations, easing, and transforms with reduced-motion fallback.',
|
||||
className: 'motion-fade-in',
|
||||
},
|
||||
parameters: {
|
||||
a11y: {
|
||||
element: '#motion-token-preview',
|
||||
},
|
||||
},
|
||||
render: ({ title, description, className }) => ({
|
||||
template: `
|
||||
<section id="motion-token-preview" style="display:grid; gap:12px; max-width:720px;">
|
||||
<div class="card {{className}}">
|
||||
<h3>{{title}}</h3>
|
||||
<p>{{description}}</p>
|
||||
<div class="chips">
|
||||
<span class="chip">duration-md: var(--motion-duration-md)</span>
|
||||
<span class="chip">ease: var(--motion-ease-standard)</span>
|
||||
<span class="chip">translate: var(--motion-translate-md)</span>
|
||||
<span class="chip">scale: var(--motion-scale-sm)</span>
|
||||
</div>
|
||||
<button class="cta motion-scale-pop">Primary action</button>
|
||||
</div>
|
||||
<div class="card motion-slide-up">
|
||||
<h4>Reduced motion</h4>
|
||||
<p>Toggle "Reduced Motion" in toolbar to verify zero-duration paths.</p>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
color: #0f172a;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
h3, h4 {
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 12px 0;
|
||||
color: #475569;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.chip {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #edf2ff;
|
||||
color: #1e3a8a;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cta {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform var(--motion-duration-sm) var(--motion-ease-standard),
|
||||
box-shadow var(--motion-duration-sm) var(--motion-ease-standard);
|
||||
}
|
||||
.cta:hover {
|
||||
transform: translateY(calc(-1 * var(--motion-translate-sm)));
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
[data-reduce-motion='1'] .cta,
|
||||
[data-reduce-motion='true'] .cta {
|
||||
transform: none !important;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
`,
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Tokens: StoryObj<CardArgs> = {};
|
||||
@@ -1 +1,52 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
@import './styles/tokens/motion';
|
||||
|
||||
/* Global motion helpers */
|
||||
.motion-fade-in {
|
||||
animation: fade-in var(--motion-duration-md) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.motion-slide-up {
|
||||
animation: slide-up var(--motion-duration-lg) var(--motion-ease-entrance);
|
||||
}
|
||||
|
||||
.motion-scale-pop {
|
||||
animation: scale-pop var(--motion-duration-sm) var(--motion-ease-bounce);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: var(--motion-opacity-hidden);
|
||||
}
|
||||
to {
|
||||
opacity: var(--motion-opacity-visible);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(var(--motion-translate-lg));
|
||||
opacity: var(--motion-opacity-muted);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: var(--motion-opacity-visible);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-pop {
|
||||
from {
|
||||
transform: scale(var(--motion-scale-md));
|
||||
opacity: var(--motion-opacity-muted);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: var(--motion-opacity-visible);
|
||||
}
|
||||
}
|
||||
|
||||
[data-reduce-motion='1'] *,
|
||||
[data-reduce-motion='true'] * {
|
||||
animation-duration: 0ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
54
src/Web/StellaOps.Web/src/styles/tokens/_motion.scss
Normal file
54
src/Web/StellaOps.Web/src/styles/tokens/_motion.scss
Normal file
@@ -0,0 +1,54 @@
|
||||
:root {
|
||||
/* Durations */
|
||||
--motion-duration-xs: 80ms;
|
||||
--motion-duration-sm: 140ms;
|
||||
--motion-duration-md: 200ms;
|
||||
--motion-duration-lg: 260ms;
|
||||
--motion-duration-xl: 320ms;
|
||||
|
||||
/* Easing */
|
||||
--motion-ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
--motion-ease-entrance: cubic-bezier(0.18, 0.89, 0.32, 1);
|
||||
--motion-ease-exit: cubic-bezier(0.36, 0, 0.66, -0.56);
|
||||
--motion-ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* Distances / transforms */
|
||||
--motion-translate-sm: 4px;
|
||||
--motion-translate-md: 8px;
|
||||
--motion-translate-lg: 16px;
|
||||
--motion-scale-sm: 0.98;
|
||||
--motion-scale-md: 0.96;
|
||||
|
||||
/* Opacity steps */
|
||||
--motion-opacity-hidden: 0;
|
||||
--motion-opacity-muted: 0.6;
|
||||
--motion-opacity-visible: 1;
|
||||
}
|
||||
|
||||
[data-reduce-motion='1'],
|
||||
[data-reduce-motion='true'] {
|
||||
--motion-duration-xs: 0ms;
|
||||
--motion-duration-sm: 0ms;
|
||||
--motion-duration-md: 0ms;
|
||||
--motion-duration-lg: 0ms;
|
||||
--motion-duration-xl: 0ms;
|
||||
--motion-ease-standard: linear;
|
||||
--motion-ease-entrance: linear;
|
||||
--motion-ease-exit: linear;
|
||||
--motion-ease-bounce: linear;
|
||||
--motion-translate-sm: 0px;
|
||||
--motion-translate-md: 0px;
|
||||
--motion-translate-lg: 0px;
|
||||
--motion-scale-sm: 1;
|
||||
--motion-scale-md: 1;
|
||||
}
|
||||
|
||||
@mixin reduce-motion-friendly {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@content;
|
||||
}
|
||||
[data-reduce-motion='1'] &,
|
||||
[data-reduce-motion='true'] & {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
44
src/Web/StellaOps.Web/tests/e2e/a11y-smoke.spec.ts
Normal file
44
src/Web/StellaOps.Web/tests/e2e/a11y-smoke.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const shouldFail = process.env.FAIL_ON_A11Y === '1';
|
||||
const reportDir = path.join(process.cwd(), 'test-results');
|
||||
|
||||
async function writeReport(filename: string, data: unknown) {
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(reportDir, filename), JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
async function runA11y(url: string, page: Page) {
|
||||
await page.goto(url);
|
||||
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||
const violations = [...results.violations].sort((a, b) => a.id.localeCompare(b.id));
|
||||
await writeReport(
|
||||
`a11y-${url.replace(/\\W+/g, '_') || 'home'}.json`,
|
||||
{ url: page.url(), violations }
|
||||
);
|
||||
if (shouldFail) {
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
test.describe('a11y-smoke', () => {
|
||||
test('home page baseline', async ({ page }, testInfo) => {
|
||||
const violations = await runA11y('/', page);
|
||||
testInfo.annotations.push({
|
||||
type: 'a11y',
|
||||
description: `${violations.length} violations (set FAIL_ON_A11Y=1 to fail on any)`,
|
||||
});
|
||||
});
|
||||
|
||||
test('graph explorer shell', async ({ page }, testInfo) => {
|
||||
const violations = await runA11y('/graph', page);
|
||||
testInfo.annotations.push({
|
||||
type: 'a11y',
|
||||
description: `${violations.length} violations (/graph)`,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user