AI-Studio/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingContracts.cs
2026-07-28 20:44:33 +02:00

1933 lines
90 KiB
C#

using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
namespace AIStudio.Assistants.VisualBriefing;
[JsonConverter(typeof(JsonStringEnumConverter<VisualBriefingComponentKind>))]
public enum VisualBriefingComponentKind
{
TEXT,
METRIC,
TABLE,
CHART,
ASSET,
CALLOUT,
TABS,
ACCORDION,
FILTERABLE_TABLE,
SIMULATION,
}
/// <summary>
/// Identifies the JSON shape a content slot value must have. The shape follows from the planned
/// component kind alone, so plan, prompt, validator, and compiler always agree.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<VisualBriefingSlotType>))]
public enum VisualBriefingSlotType
{
/// <summary>
/// A JSON string, number, or boolean rendered as text.
/// </summary>
TEXT,
/// <summary>
/// A tabular object with columns and rows.
/// </summary>
TABLE,
}
[JsonConverter(typeof(JsonStringEnumConverter<VisualBriefingChartKind>))]
public enum VisualBriefingChartKind
{
LINE,
AREA,
BAR,
STACKED_BAR,
SCATTER,
PIE,
DONUT,
RADAR,
}
[JsonConverter(typeof(JsonStringEnumConverter<VisualBriefingControlKind>))]
public enum VisualBriefingControlKind
{
TAB,
FILTER,
NUMBER,
RANGE,
SELECT,
}
[JsonConverter(typeof(JsonStringEnumConverter<VisualBriefingLayoutNodeKind>))]
public enum VisualBriefingLayoutNodeKind
{
SECTION,
STACK,
GRID,
COMPONENT,
}
[JsonConverter(typeof(JsonStringEnumConverter<VisualBriefingAlignment>))]
public enum VisualBriefingAlignment
{
START,
CENTER,
END,
STRETCH,
}
[JsonConverter(typeof(JsonStringEnumConverter<VisualBriefingDensity>))]
public enum VisualBriefingDensity
{
COMPACT,
COMFORTABLE,
SPACIOUS,
}
[JsonConverter(typeof(JsonStringEnumConverter<VisualBriefingTypographyScale>))]
public enum VisualBriefingTypographyScale
{
COMPACT,
BALANCED,
EDITORIAL,
DISPLAY,
}
[JsonConverter(typeof(JsonStringEnumConverter<VisualBriefingSurface>))]
public enum VisualBriefingSurface
{
PLAIN,
SUBTLE,
RAISED,
ACCENT,
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingEvidenceFact
{
[JsonRequired]
public string EvidenceId { get; set; } = string.Empty;
[JsonRequired]
public string Statement { get; set; } = string.Empty;
[JsonRequired]
public List<string> SourceIds { get; set; } = [];
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingEvidenceMetric
{
[JsonRequired]
public string EvidenceId { get; set; } = string.Empty;
[JsonRequired]
public string Label { get; set; } = string.Empty;
[JsonRequired]
public decimal Value { get; set; }
[JsonRequired]
public string Unit { get; set; } = string.Empty;
[JsonRequired]
public List<string> SourceIds { get; set; } = [];
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingEvidenceTable
{
[JsonRequired]
public string EvidenceId { get; set; } = string.Empty;
[JsonRequired]
public string Title { get; set; } = string.Empty;
[JsonRequired]
public List<string> Columns { get; set; } = [];
[JsonRequired]
public List<List<JsonElement>> Rows { get; set; } = [];
[JsonRequired]
public List<string> SourceIds { get; set; } = [];
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingEvidenceResponse
{
[JsonRequired]
public int ContractVersion { get; set; }
[JsonRequired]
public List<VisualBriefingEvidenceFact> Facts { get; set; } = [];
[JsonRequired]
public List<VisualBriefingEvidenceMetric> Metrics { get; set; } = [];
[JsonRequired]
public List<VisualBriefingEvidenceTable> Tables { get; set; } = [];
[JsonRequired]
public List<VisualBriefingSourceCoverage> SourceCoverage { get; set; } = [];
[JsonRequired]
public List<VisualBriefingAssetPlanItem> AssetPlan { get; set; } = [];
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingEvidenceArtifact
{
public int ArtifactVersion { get; set; } = VisualBriefingVersions.INTERMEDIATE_ARTIFACT;
public int ContractVersion { get; set; } = VisualBriefingVersions.EVIDENCE_CONTRACT;
public Guid ArtifactId { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public string PayloadHash { get; set; } = string.Empty;
public List<VisualBriefingEvidenceFact> Facts { get; set; } = [];
public List<VisualBriefingEvidenceMetric> Metrics { get; set; } = [];
public List<VisualBriefingEvidenceTable> Tables { get; set; } = [];
public List<VisualBriefingSourceCoverage> SourceCoverage { get; set; } = [];
public List<VisualBriefingAssetPlanItem> AssetPlan { get; set; } = [];
public string Model { get; set; } = string.Empty;
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingPlanComponent
{
[JsonRequired]
public string ComponentId { get; set; } = string.Empty;
[JsonRequired]
public VisualBriefingComponentKind Kind { get; set; }
[JsonRequired]
public List<string> EvidenceIds { get; set; } = [];
[JsonRequired]
public List<string> RequiredSlots { get; set; } = [];
[JsonRequired]
public string? AssetId { get; set; }
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingPlanSection
{
[JsonRequired]
public string SectionId { get; set; } = string.Empty;
[JsonRequired]
public string Purpose { get; set; } = string.Empty;
[JsonRequired]
public List<VisualBriefingPlanComponent> Components { get; set; } = [];
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingPlanResponse
{
[JsonRequired]
public int ContractVersion { get; set; }
[JsonRequired]
public List<VisualBriefingPlanSection> Sections { get; set; } = [];
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingPlanArtifact
{
public int ArtifactVersion { get; set; } = VisualBriefingVersions.INTERMEDIATE_ARTIFACT;
public int ContractVersion { get; set; } = VisualBriefingVersions.PLAN_CONTRACT;
public Guid ArtifactId { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public string PayloadHash { get; set; } = string.Empty;
public List<VisualBriefingPlanSection> Sections { get; set; } = [];
public string StructuralSignature { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingSlotValue
{
[JsonRequired]
public string SlotId { get; set; } = string.Empty;
[JsonRequired]
public JsonElement Value { get; set; }
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingChartSeries
{
[JsonRequired]
public string Name { get; set; } = string.Empty;
[JsonRequired]
public List<decimal> Values { get; set; } = [];
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingChartSpec
{
[JsonRequired]
public string ComponentId { get; set; } = string.Empty;
[JsonRequired]
public VisualBriefingChartKind Kind { get; set; }
[JsonRequired]
public string Title { get; set; } = string.Empty;
[JsonRequired]
public List<string> Categories { get; set; } = [];
[JsonRequired]
public List<VisualBriefingChartSeries> Series { get; set; } = [];
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingControlOption
{
[JsonRequired]
public string Value { get; set; } = string.Empty;
[JsonRequired]
public string Label { get; set; } = string.Empty;
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingControlSpec
{
[JsonRequired]
public string ControlId { get; set; } = string.Empty;
[JsonRequired]
public string ComponentId { get; set; } = string.Empty;
[JsonRequired]
public VisualBriefingControlKind Kind { get; set; }
[JsonRequired]
public JsonElement InitialValue { get; set; }
[JsonRequired]
public List<VisualBriefingControlOption> Options { get; set; } = [];
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingFormulaSpec
{
[JsonRequired]
public string ComponentId { get; set; } = string.Empty;
[JsonRequired]
public string OutputSlotId { get; set; } = string.Empty;
[JsonRequired]
public VisualBriefingFormulaNode Formula { get; set; } = new();
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingContentResponse
{
[JsonRequired]
public int ContractVersion { get; set; }
[JsonRequired]
public List<VisualBriefingSlotValue> Slots { get; set; } = [];
[JsonRequired]
public List<VisualBriefingChartSpec> Charts { get; set; } = [];
[JsonRequired]
public List<VisualBriefingControlSpec> Controls { get; set; } = [];
[JsonRequired]
public List<VisualBriefingFormulaSpec> Formulas { get; set; } = [];
[JsonRequired]
public Dictionary<string, string> AccessibilityTexts { get; set; } = new(StringComparer.Ordinal);
[JsonRequired]
public Dictionary<string, string> VisibleLabels { get; set; } = new(StringComparer.Ordinal);
[JsonRequired]
public Dictionary<string, string>? CustomLanguageLabels { get; set; }
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingResponsiveColumns
{
[JsonRequired]
public int Mobile { get; set; } = 1;
[JsonRequired]
public int Tablet { get; set; } = 1;
[JsonRequired]
public int Desktop { get; set; } = 1;
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingLayoutNode
{
[JsonRequired]
public string NodeId { get; set; } = string.Empty;
[JsonRequired]
public VisualBriefingLayoutNodeKind Kind { get; set; }
[JsonRequired]
public string? ComponentId { get; set; }
[JsonRequired]
public List<VisualBriefingLayoutNode> Children { get; set; } = [];
[JsonRequired]
public VisualBriefingResponsiveColumns? Columns { get; set; }
[JsonRequired]
public int Span { get; set; } = 1;
[JsonRequired]
public int Order { get; set; }
[JsonRequired]
public bool Emphasized { get; set; }
[JsonRequired]
public VisualBriefingAlignment Alignment { get; set; }
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingDesignTokens
{
[JsonRequired]
public string PrimaryColor { get; set; } = "#2563eb";
[JsonRequired]
public string AccentColor { get; set; } = "#7c3aed";
[JsonRequired]
public string TextColor { get; set; } = "#172033";
[JsonRequired]
public string BackgroundColor { get; set; } = "#ffffff";
[JsonRequired]
public int SpacingScale { get; set; } = 4;
[JsonRequired]
public int Radius { get; set; } = 12;
[JsonRequired]
public VisualBriefingTypographyScale TypographyScale { get; set; }
[JsonRequired]
public VisualBriefingDensity Density { get; set; }
[JsonRequired]
public VisualBriefingSurface Surface { get; set; }
}
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class VisualBriefingDesignResponse
{
[JsonRequired]
public int ContractVersion { get; set; }
[JsonRequired]
public VisualBriefingLayoutNode Layout { get; set; } = new();
[JsonRequired]
public VisualBriefingDesignTokens Tokens { get; set; } = new();
}
public sealed record VisualBriefingCompilationResult(
JsonElement Data,
string TemplateHtml,
string Css,
string TemplateHash,
string CssHash);
/// <summary>
/// Guards the parts AI Studio compiles itself. Everything the model controls is validated on the
/// JSON contract before compilation, so a rejected compiler output is always a defect in AI Studio.
/// Such a defect must never be reported as a contract violation, because the model cannot repair it.
/// </summary>
internal static class VisualBriefingCompilerInvariant
{
private const string USER_MESSAGE =
"AI Studio could not assemble this briefing because its own compiler produced an invalid part. This is a defect in AI Studio, not in the model response.";
/// <summary>
/// Fails the build when the compiled parts violate the artifact contract.
/// </summary>
/// <param name="stage">The stage running the compilation.</param>
/// <param name="compilerIssue">The compiler issue, or an empty string when the parts are valid.</param>
/// <exception cref="VisualBriefingBuildException">Thrown when the compiled parts are invalid.</exception>
internal static void Guard(VisualBriefingBuildStage stage, string compilerIssue)
{
if (string.IsNullOrEmpty(compilerIssue))
return;
throw new VisualBriefingBuildException(
VisualBriefingFailureCode.COMPILER_INVARIANT_VIOLATED,
stage,
USER_MESSAGE,
$"Stage={stage}; CompilerIssue={compilerIssue}");
}
/// <summary>
/// Runs a compilation and translates its structural failures into a compiler invariant failure.
/// </summary>
/// <typeparam name="T">The compilation result type.</typeparam>
/// <param name="stage">The stage running the compilation.</param>
/// <param name="compile">The compilation to run.</param>
/// <returns>The compilation result.</returns>
/// <exception cref="VisualBriefingBuildException">Thrown when the compilation fails structurally.</exception>
internal static T Guard<T>(VisualBriefingBuildStage stage, Func<T> compile)
{
try
{
return compile();
}
catch (InvalidDataException exception)
{
throw new VisualBriefingBuildException(
VisualBriefingFailureCode.COMPILER_INVARIANT_VIOLATED,
stage,
USER_MESSAGE,
$"Stage={stage}; CompilerIssue={exception.Message}");
}
}
}
/// <summary>
/// Maps briefing sources to the short handles the model works with. Internal source identity stays
/// a GUID, but a model would have to reproduce it verbatim dozens of times, which it does not do
/// reliably. Prompt, validator, and source references all read the same canonical order from here.
/// </summary>
internal static class VisualBriefingSourceHandles
{
/// <summary>
/// Orders the sources canonically and pairs them with their handle. The order matches the order
/// in which VisualBriefingSourcePreparationService builds the model attachments.
/// </summary>
/// <param name="manifest">The briefing manifest.</param>
/// <returns>The handles with their sources, in canonical order.</returns>
internal static IReadOnlyList<(string Handle, VisualBriefingSource Source)> Map(VisualBriefingManifest manifest) =>
manifest.Sources
.OrderBy(source => source.SourceId)
.Select((source, index) => (Handle: Handle(index), Source: source))
.ToArray();
/// <summary>
/// Names the handle of the source at one canonical position.
/// </summary>
/// <param name="index">The zero-based canonical position.</param>
/// <returns>The source handle.</returns>
internal static string Handle(int index) => $"s{index + 1}";
}
/// <summary>
/// Derives which component texts the model has to supply. A component text is either an assistive
/// alternative that never becomes visible, or a visible label that also acts as the accessible name.
/// Validator, layout compiler, and the content prompt all read these roles from here.
/// </summary>
internal static class VisualBriefingComponentTexts
{
/// <summary>
/// Determines whether a component renders a visible label. The caption of a table and the
/// summary of an accordion are visible and are the accessible name of their component.
/// </summary>
/// <param name="kind">The planned component kind.</param>
/// <returns>True when the component requires a visible label.</returns>
internal static bool RequiresVisibleLabel(VisualBriefingComponentKind kind) =>
kind is VisualBriefingComponentKind.TABLE or
VisualBriefingComponentKind.FILTERABLE_TABLE or
VisualBriefingComponentKind.ACCORDION;
/// <summary>
/// Determines whether a component requires an assistive alternative that never becomes visible.
/// Charts bind it as an aria-label, and components with controls label those controls with it.
/// </summary>
/// <param name="kind">The planned component kind.</param>
/// <returns>True when the model has to supply an accessibility text.</returns>
internal static bool RequiresAccessibilityText(VisualBriefingComponentKind kind) =>
kind is VisualBriefingComponentKind.CHART or
VisualBriefingComponentKind.SIMULATION or
VisualBriefingComponentKind.FILTERABLE_TABLE;
/// <summary>
/// Determines whether the accessibility text of a component comes from the validated evidence
/// instead of the content model. Asset alternatives are written once by the evidence agent.
/// </summary>
/// <param name="kind">The planned component kind.</param>
/// <returns>True when AI Studio supplies the accessibility text.</returns>
internal static bool InheritsAccessibilityText(VisualBriefingComponentKind kind) =>
kind is VisualBriefingComponentKind.ASSET;
/// <summary>
/// Lists the component IDs the model has to supply an accessibility text for.
/// </summary>
/// <param name="components">The planned components.</param>
/// <returns>The component IDs in plan order.</returns>
internal static string[] AccessibilityTextKeys(IEnumerable<VisualBriefingPlanComponent> components) =>
components.Where(component => RequiresAccessibilityText(component.Kind))
.Select(component => component.ComponentId)
.ToArray();
/// <summary>
/// Lists the component IDs the model has to supply a visible label for.
/// </summary>
/// <param name="components">The planned components.</param>
/// <returns>The component IDs in plan order.</returns>
internal static string[] VisibleLabelKeys(IEnumerable<VisualBriefingPlanComponent> components) =>
components.Where(component => RequiresVisibleLabel(component.Kind))
.Select(component => component.ComponentId)
.ToArray();
}
/// <summary>
/// Derives the required JSON shape of every planned content slot. Validator, layout compiler, and
/// the content prompt all read the slot types from here so that they cannot drift apart.
/// </summary>
internal static class VisualBriefingSlotTypes
{
/// <summary>
/// Determines the slot type of one planned slot.
/// </summary>
/// <param name="component">The planned component owning the slot.</param>
/// <param name="slotId">The planned slot ID.</param>
/// <returns>The required slot type.</returns>
internal static VisualBriefingSlotType Expected(VisualBriefingPlanComponent component, string slotId) =>
IsTableDataSlot(component, slotId) ? VisualBriefingSlotType.TABLE : VisualBriefingSlotType.TEXT;
/// <summary>
/// Determines whether a slot carries the tabular data of a table component.
/// </summary>
/// <param name="component">The planned component owning the slot.</param>
/// <param name="slotId">The planned slot ID.</param>
/// <returns>True when the slot carries tabular data.</returns>
internal static bool IsTableDataSlot(VisualBriefingPlanComponent component, string slotId) =>
component.Kind is VisualBriefingComponentKind.TABLE or VisualBriefingComponentKind.FILTERABLE_TABLE &&
component.RequiredSlots.Count > 0 &&
string.Equals(component.RequiredSlots[0], slotId, StringComparison.Ordinal);
/// <summary>
/// Maps every planned slot to its required slot type.
/// </summary>
/// <param name="sections">The planned sections.</param>
/// <returns>The slot types by slot ID.</returns>
internal static Dictionary<string, VisualBriefingSlotType> Map(IReadOnlyList<VisualBriefingPlanSection> sections)
{
Dictionary<string, VisualBriefingSlotType> types = new(StringComparer.Ordinal);
foreach (var component in sections.SelectMany(section => section.Components))
foreach (var slotId in component.RequiredSlots)
types[slotId] = Expected(component, slotId);
return types;
}
/// <summary>
/// Names the required shape of a slot type. The wording stays within the sanitized character
/// set of structured diagnostics, see VisualBriefingStructuredResponseProcessor.SafeExpected.
/// </summary>
/// <param name="type">The slot type.</param>
/// <returns>The human-readable shape description.</returns>
internal static string Describe(VisualBriefingSlotType type) => type switch
{
VisualBriefingSlotType.TABLE => "object with a columns array and a rows array of cells arrays",
_ => "string, number, or boolean",
};
/// <summary>
/// Checks a slot value against its required slot type.
/// </summary>
/// <param name="type">The required slot type.</param>
/// <param name="value">The slot value returned by the model.</param>
/// <returns>A short reason when the value does not match, otherwise an empty string.</returns>
internal static string Validate(VisualBriefingSlotType type, JsonElement value)
{
if (type is VisualBriefingSlotType.TEXT)
return value.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False
? string.Empty
: "A text slot requires a string, number, or boolean value.";
if (value.ValueKind is not JsonValueKind.Object)
return "A table slot requires an object with columns and rows.";
if (value.EnumerateObject().Any(property => property.Name is not "columns" and not "rows"))
return "A table slot must contain only columns and rows.";
if (!value.TryGetProperty("columns", out var columns) ||
columns.ValueKind is not JsonValueKind.Array ||
columns.GetArrayLength() == 0)
return "A table slot requires a non-empty columns array.";
if (columns.EnumerateArray().Any(column =>
column.ValueKind is not JsonValueKind.String ||
string.IsNullOrWhiteSpace(column.GetString())))
return "Every table column requires a non-empty name.";
if (!value.TryGetProperty("rows", out var rows) || rows.ValueKind is not JsonValueKind.Array)
return "A table slot requires a rows array.";
var columnCount = columns.GetArrayLength();
foreach (var row in rows.EnumerateArray())
{
if (row.ValueKind is not JsonValueKind.Object ||
row.EnumerateObject().Any(property => property.Name is not "cells"))
return "Every table row requires exactly one cells array.";
if (!row.TryGetProperty("cells", out var cells) || cells.ValueKind is not JsonValueKind.Array)
return "Every table row requires a cells array.";
if (cells.GetArrayLength() != columnCount)
return "Every table row requires exactly one cell per column.";
if (cells.EnumerateArray().Any(cell =>
cell.ValueKind is not (JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False)))
return "Every table cell requires a string, number, or boolean value.";
}
return string.Empty;
}
}
internal static partial class VisualBriefingValidation
{
private const int MAX_OPTION_VALUE_LENGTH = 128;
private static readonly Regex ID = IdRegex();
private static readonly Regex COLOR = ColorRegex();
/// <summary>
/// Lists tokens that never occur in ordinary target-language prose. Broader patterns such as a
/// bare "document." or "=>" are deliberately absent: they reject normal sentences, and model text
/// only ever reaches the artifact as text content.
/// </summary>
private static readonly string[] FORBIDDEN_MODEL_TEXT =
[
"data-mwai-", "javascript:", "echarts", "function(",
];
internal static VisualBriefingContractIssue? ValidateEvidence(
VisualBriefingManifest manifest,
VisualBriefingEvidenceResponse response)
{
if (response.ContractVersion != VisualBriefingVersions.EVIDENCE_CONTRACT)
return Invalid(
"The evidence response uses an unsupported contract version.",
VisualBriefingValidationRule.CONTRACT_VERSION_UNSUPPORTED,
"$.contractVersion",
expected: "supported contract version");
var evidenceIdLocations = response.Facts
.Select((item, index) => (item.EvidenceId, Path: $"$.facts[{index}].evidenceId"))
.Concat(response.Metrics
.Select((item, index) => (item.EvidenceId, Path: $"$.metrics[{index}].evidenceId")))
.Concat(response.Tables
.Select((item, index) => (item.EvidenceId, Path: $"$.tables[{index}].evidenceId")))
.ToArray();
var invalidEvidenceId = FindInvalidOrDuplicateId(evidenceIdLocations);
if (invalidEvidenceId is not null)
return Invalid(
"Evidence IDs must be valid and unique.",
VisualBriefingValidationRule.ID_INVALID,
invalidEvidenceId,
"evidenceId",
"unique lowercase ID");
var sourceIds = VisualBriefingSourceHandles.Map(manifest)
.Select(item => item.Handle)
.ToHashSet(StringComparer.Ordinal);
if (response.SourceCoverage.Count != sourceIds.Count ||
response.SourceCoverage.Select(item => item.SourceId).Distinct().Count() != sourceIds.Count ||
response.SourceCoverage.Any(item =>
!sourceIds.Contains(item.SourceId) ||
string.IsNullOrWhiteSpace(item.Reason)))
return new(
VisualBriefingFailureCode.SOURCE_COVERAGE_INVALID,
"Source coverage must contain every source exactly once.",
VisualBriefingValidationRule.SOURCE_COVERAGE_INVALID);
if (response.Facts.Any(item =>
item.SourceIds.Count == 0 ||
item.SourceIds.Distinct().Count() != item.SourceIds.Count ||
item.SourceIds.Any(id => !sourceIds.Contains(id))) ||
response.Metrics.Any(item =>
item.SourceIds.Count == 0 ||
item.SourceIds.Distinct().Count() != item.SourceIds.Count ||
item.SourceIds.Any(id => !sourceIds.Contains(id))) ||
response.Tables.Any(item =>
item.SourceIds.Count == 0 ||
item.SourceIds.Distinct().Count() != item.SourceIds.Count ||
item.SourceIds.Any(id => !sourceIds.Contains(id)) ||
item.Columns.Count == 0 ||
item.Rows.Any(row => row.Count != item.Columns.Count)) ||
response.Facts.Any(item => string.IsNullOrWhiteSpace(item.Statement)) ||
response.Metrics.Any(item => string.IsNullOrWhiteSpace(item.Label)) ||
response.Tables.Any(item => string.IsNullOrWhiteSpace(item.Title)))
return Invalid(
"Every evidence item must reference a supplied source.",
VisualBriefingValidationRule.REFERENCE_INVALID);
var assetIds = manifest.Sources
.Where(source => source.Kind is VisualBriefingSourceKind.VISUAL_ASSET)
.Select(source => source.AssetId)
.ToHashSet(StringComparer.Ordinal);
if (response.AssetPlan.Count != assetIds.Count ||
response.AssetPlan.Select(item => item.AssetId).Distinct(StringComparer.Ordinal).Count() != assetIds.Count ||
response.AssetPlan.Any(item =>
!assetIds.Contains(item.AssetId) ||
string.IsNullOrWhiteSpace(item.Description) ||
string.IsNullOrWhiteSpace(item.AltText)))
return new(
VisualBriefingFailureCode.ASSET_PLAN_INVALID,
"The asset plan must contain every visual asset exactly once.",
VisualBriefingValidationRule.ASSET_PLAN_INVALID);
return ContainsForbidden(response)
? Invalid(
"Evidence must not contain HTML, CSS, JavaScript, runtime bindings, or chart-library options.",
VisualBriefingValidationRule.MODEL_MARKUP_PROHIBITED)
: null;
}
internal static VisualBriefingContractIssue? ValidatePlan(
VisualBriefingEvidenceArtifact evidence,
VisualBriefingPlanResponse response)
{
if (response.ContractVersion != VisualBriefingVersions.PLAN_CONTRACT)
return Invalid(
"The plan response uses an unsupported contract version.",
VisualBriefingValidationRule.CONTRACT_VERSION_UNSUPPORTED,
"$.contractVersion",
expected: "supported contract version");
var evidenceIds = evidence.Facts.Select(item => item.EvidenceId)
.Concat(evidence.Metrics.Select(item => item.EvidenceId))
.Concat(evidence.Tables.Select(item => item.EvidenceId))
.ToHashSet(StringComparer.Ordinal);
var components = response.Sections.SelectMany(item => item.Components).ToArray();
if (response.Sections.Count == 0)
return Invalid(
"Plan section and component IDs must be valid and unique.",
VisualBriefingValidationRule.ID_INVALID,
"$.sections",
expected: "non-empty section array");
var invalidSectionId = FindInvalidOrDuplicateId(response.Sections
.Select((section, sectionIndex) =>
(section.SectionId, Path: $"$.sections[{sectionIndex}].sectionId")));
if (invalidSectionId is not null)
return Invalid(
"Plan section and component IDs must be valid and unique.",
VisualBriefingValidationRule.ID_INVALID,
invalidSectionId,
"sectionId",
"unique lowercase ID");
var emptyPurposeIndex = response.Sections.FindIndex(section => string.IsNullOrWhiteSpace(section.Purpose));
if (emptyPurposeIndex >= 0)
return Invalid(
"Every plan section requires a purpose.",
VisualBriefingValidationRule.REFERENCE_INVALID,
$"$.sections[{emptyPurposeIndex}].purpose",
"purpose",
"non-empty string");
var invalidComponentId = FindInvalidOrDuplicateId(response.Sections
.SelectMany((section, sectionIndex) => section.Components
.Select((component, componentIndex) =>
(component.ComponentId,
Path: $"$.sections[{sectionIndex}].components[{componentIndex}].componentId"))));
if (invalidComponentId is not null)
return Invalid(
"Plan section and component IDs must be valid and unique.",
VisualBriefingValidationRule.ID_INVALID,
invalidComponentId,
"componentId",
"unique lowercase ID");
var invalidSlotId = FindInvalidOrDuplicateId(response.Sections
.SelectMany((section, sectionIndex) => section.Components
.SelectMany((component, componentIndex) => component.RequiredSlots
.Select((slotId, slotIndex) =>
(slotId,
Path: $"$.sections[{sectionIndex}].components[{componentIndex}].requiredSlots[{slotIndex}]")))));
if (invalidSlotId is not null)
return Invalid(
"Plan slot IDs must be valid and unique.",
VisualBriefingValidationRule.ID_INVALID,
invalidSlotId,
expected: "unique lowercase ID");
if (components.Any(item =>
item.EvidenceIds.Count == 0 ||
item.EvidenceIds.Distinct(StringComparer.Ordinal).Count() != item.EvidenceIds.Count ||
item.EvidenceIds.Any(id => !evidenceIds.Contains(id)) ||
item.RequiredSlots.Count == 0 ||
!UniqueIds(item.RequiredSlots)))
return Invalid(
"Every component must reference valid evidence and unique required slots.",
VisualBriefingValidationRule.REFERENCE_INVALID);
var plannedAssetIds = components
.Where(item => item.Kind is VisualBriefingComponentKind.ASSET)
.Select(item => item.AssetId)
.ToArray();
var evidenceAssetIds = evidence.AssetPlan.Select(item => item.AssetId).ToHashSet(StringComparer.Ordinal);
if (components.Any(item =>
item.Kind is VisualBriefingComponentKind.ASSET && string.IsNullOrWhiteSpace(item.AssetId) ||
item.Kind is not VisualBriefingComponentKind.ASSET && item.AssetId is not null) ||
plannedAssetIds.Any(item => item is null) ||
plannedAssetIds.Distinct(StringComparer.Ordinal).Count() != plannedAssetIds.Length ||
!plannedAssetIds.Select(item => item!).ToHashSet(StringComparer.Ordinal).SetEquals(evidenceAssetIds) ||
components.Where(item => item.Kind is not VisualBriefingComponentKind.ASSET)
.Any(item => item.AssetId is not null))
return Invalid(
"The plan must include every visual asset exactly once.",
VisualBriefingValidationRule.ASSET_PLAN_INVALID);
return ContainsForbidden(response)
? Invalid(
"The plan must not contain HTML, CSS, JavaScript, runtime bindings, or chart-library options.",
VisualBriefingValidationRule.MODEL_MARKUP_PROHIBITED)
: null;
}
internal static VisualBriefingContractIssue? ValidateContent(
VisualBriefingPlanArtifact plan,
VisualBriefingContentResponse response)
{
if (response.ContractVersion != VisualBriefingVersions.CONTENT_CONTRACT)
return Invalid(
"The content response uses an unsupported contract version.",
VisualBriefingValidationRule.CONTRACT_VERSION_UNSUPPORTED,
"$.contractVersion",
expected: "supported contract version");
var components = plan.Sections.SelectMany(section => section.Components).ToArray();
var componentById = components.ToDictionary(item => item.ComponentId, StringComparer.Ordinal);
var chartComponentIds = components
.Where(item => item.Kind is VisualBriefingComponentKind.CHART)
.Select(item => item.ComponentId)
.ToHashSet(StringComparer.Ordinal);
var requiredSlots = components.SelectMany(item => item.RequiredSlots).ToArray();
var slots = response.Slots.Select(item => item.SlotId).ToArray();
var duplicateSlotIndex = FindDuplicateIndex(slots);
if (duplicateSlotIndex >= 0)
return Invalid(
"Every required content slot must be fulfilled exactly once.",
VisualBriefingValidationRule.SLOT_FULFILLMENT_INVALID,
$"$.slots[{duplicateSlotIndex}].slotId",
"slotId",
"unique planned slot ID");
var requiredSlotSet = requiredSlots.ToHashSet(StringComparer.Ordinal);
var unknownSlotIndex = Array.FindIndex(slots, slotId => !requiredSlotSet.Contains(slotId));
if (unknownSlotIndex >= 0)
return Invalid(
"Every required content slot must be fulfilled exactly once.",
VisualBriefingValidationRule.SLOT_FULFILLMENT_INVALID,
$"$.slots[{unknownSlotIndex}].slotId",
"slotId",
"planned slot ID");
if (slots.Length != requiredSlots.Length ||
!slots.ToHashSet(StringComparer.Ordinal).SetEquals(requiredSlotSet))
return Invalid(
"Every required content slot must be fulfilled exactly once.",
VisualBriefingValidationRule.SLOT_FULFILLMENT_INVALID,
"$.slots",
expected: "every planned slot exactly once");
var slotTypes = VisualBriefingSlotTypes.Map(plan.Sections);
for (var slotIndex = 0; slotIndex < response.Slots.Count; slotIndex++)
{
var slot = response.Slots[slotIndex];
var slotType = slotTypes[slot.SlotId];
var slotTypeIssue = VisualBriefingSlotTypes.Validate(slotType, slot.Value);
if (!string.IsNullOrEmpty(slotTypeIssue))
return Invalid(
slotTypeIssue,
VisualBriefingValidationRule.SLOT_VALUE_TYPE_INVALID,
$"$.slots[{slotIndex}].value",
"value",
VisualBriefingSlotTypes.Describe(slotType));
// AI Studio derives the filter options of a filterable table from the first column and
// compares them against the rendered cell text, so those cells must be text:
var slotComponent = components.FirstOrDefault(item =>
VisualBriefingSlotTypes.IsTableDataSlot(item, slot.SlotId) &&
item.Kind is VisualBriefingComponentKind.FILTERABLE_TABLE);
if (slotComponent is not null && !HasTextFirstColumn(slot.Value))
return Invalid(
"The first column of a filterable table must contain text values.",
VisualBriefingValidationRule.SLOT_VALUE_TYPE_INVALID,
$"$.slots[{slotIndex}].value",
"value",
"string value in the first cell of every row");
}
HashSet<string> seenCharts = new(StringComparer.Ordinal);
for (var chartIndex = 0; chartIndex < response.Charts.Count; chartIndex++)
{
var chart = response.Charts[chartIndex];
if (!chartComponentIds.Contains(chart.ComponentId))
return Invalid(
"A chart targets a component that is not a planned chart.",
VisualBriefingValidationRule.CHART_SET_INVALID,
$"$.charts[{chartIndex}].componentId",
"componentId",
"planned CHART component ID");
if (!seenCharts.Add(chart.ComponentId))
return Invalid(
"Every planned chart component requires exactly one chart.",
VisualBriefingValidationRule.CHART_SET_INVALID,
$"$.charts[{chartIndex}].componentId",
"componentId",
"unique planned CHART component ID");
if (string.IsNullOrWhiteSpace(chart.Title))
return Invalid(
"Every chart requires a title.",
VisualBriefingValidationRule.CHART_DATA_INVALID,
$"$.charts[{chartIndex}].title",
"title",
"non-empty target-language string");
if (chart.Categories.Count == 0)
return Invalid(
"Every chart requires categories.",
VisualBriefingValidationRule.CHART_DATA_INVALID,
$"$.charts[{chartIndex}].categories",
"categories",
"non-empty string array");
var emptyCategoryIndex = chart.Categories.FindIndex(string.IsNullOrWhiteSpace);
if (emptyCategoryIndex >= 0)
return Invalid(
"Chart categories must be non-empty.",
VisualBriefingValidationRule.CHART_DATA_INVALID,
$"$.charts[{chartIndex}].categories[{emptyCategoryIndex}]",
expected: "non-empty string");
if (chart.Series.Count == 0)
return Invalid(
"Every chart requires at least one data series.",
VisualBriefingValidationRule.CHART_DATA_INVALID,
$"$.charts[{chartIndex}].series",
"series",
"non-empty series array");
if (chart.Kind is VisualBriefingChartKind.PIE or VisualBriefingChartKind.DONUT &&
chart.Series.Count != 1)
return Invalid(
"Pie and donut charts require exactly one data series.",
VisualBriefingValidationRule.CHART_DATA_INVALID,
$"$.charts[{chartIndex}].series",
"series",
"exactly one series");
for (var seriesIndex = 0; seriesIndex < chart.Series.Count; seriesIndex++)
{
var series = chart.Series[seriesIndex];
if (string.IsNullOrWhiteSpace(series.Name))
return Invalid(
"Every chart series requires a name.",
VisualBriefingValidationRule.CHART_DATA_INVALID,
$"$.charts[{chartIndex}].series[{seriesIndex}].name",
"name",
"non-empty target-language string");
if (series.Values.Count != chart.Categories.Count)
return Invalid(
"Every chart series requires one value per category.",
VisualBriefingValidationRule.CHART_DATA_INVALID,
$"$.charts[{chartIndex}].series[{seriesIndex}].values",
"values",
"one numeric value per category");
}
}
if (!seenCharts.SetEquals(chartComponentIds))
return Invalid(
"Every planned chart component requires exactly one chart.",
VisualBriefingValidationRule.CHART_SET_INVALID,
"$.charts",
expected: "exactly one chart for every planned CHART component");
HashSet<string> seenControls = new(StringComparer.Ordinal);
for (var controlIndex = 0; controlIndex < response.Controls.Count; controlIndex++)
{
var control = response.Controls[controlIndex];
if (!IsUsableId(control.ControlId) || !seenControls.Add(control.ControlId))
return Invalid(
"Control IDs must be valid and unique.",
VisualBriefingValidationRule.CONTROL_ID_INVALID,
$"$.controls[{controlIndex}].controlId",
"controlId",
"unique lowercase ID");
if (!componentById.TryGetValue(control.ComponentId, out var component))
return Invalid(
"A control targets an unknown component.",
VisualBriefingValidationRule.CONTROL_TARGET_INVALID,
$"$.controls[{controlIndex}].componentId",
"componentId",
"planned interactive component ID");
if (!ControlMatchesComponent(control.Kind, component.Kind))
return Invalid(
"A control kind is incompatible with its planned component.",
VisualBriefingValidationRule.CONTROL_TARGET_INVALID,
$"$.controls[{controlIndex}].kind",
"kind",
ExpectedControlKinds(component.Kind));
var controlIssue = ValidateControlState(control, controlIndex);
if (controlIssue is not null)
return controlIssue;
}
foreach (var component in components)
{
var controls = response.Controls
.Where(control => control.ComponentId == component.ComponentId)
.ToArray();
if (component.Kind is VisualBriefingComponentKind.TABS)
{
if (controls.Length != 1 || controls[0].Kind is not VisualBriefingControlKind.TAB)
return Invalid(
"Every tabs component requires exactly one TAB control.",
VisualBriefingValidationRule.CONTROL_REQUIREMENT_INVALID,
"$.controls",
expected: "exactly one TAB control for every planned TABS component");
if (controls[0].Options.Count != component.RequiredSlots.Count)
return Invalid(
"Every tabs option requires one matching planned slot.",
VisualBriefingValidationRule.CONTROL_REQUIREMENT_INVALID,
$"$.controls[{response.Controls.IndexOf(controls[0])}].options",
"options",
"one option per planned tab slot");
}
else if (component.Kind is VisualBriefingComponentKind.SIMULATION &&
controls.All(control =>
control.Kind is not (
VisualBriefingControlKind.NUMBER or
VisualBriefingControlKind.RANGE or
VisualBriefingControlKind.SELECT)))
return Invalid(
"Every simulation requires at least one typed input control.",
VisualBriefingValidationRule.CONTROL_REQUIREMENT_INVALID,
"$.controls",
expected: "NUMBER, RANGE, or SELECT control for every planned SIMULATION component");
}
HashSet<string> formulaOutputs = new(StringComparer.Ordinal);
for (var formulaIndex = 0; formulaIndex < response.Formulas.Count; formulaIndex++)
{
var formula = response.Formulas[formulaIndex];
if (!componentById.TryGetValue(formula.ComponentId, out var component) ||
component.Kind is not VisualBriefingComponentKind.SIMULATION)
return Invalid(
"A formula must target a planned simulation.",
VisualBriefingValidationRule.FORMULA_TARGET_INVALID,
$"$.formulas[{formulaIndex}].componentId",
"componentId",
"planned SIMULATION component ID");
if (!component.RequiredSlots.Contains(formula.OutputSlotId, StringComparer.Ordinal))
return Invalid(
"A formula output must target a slot of its simulation.",
VisualBriefingValidationRule.FORMULA_TARGET_INVALID,
$"$.formulas[{formulaIndex}].outputSlotId",
"outputSlotId",
"slot ID planned for the same SIMULATION component");
if (!formulaOutputs.Add(formula.OutputSlotId))
return Invalid(
"Formula output slots must be unique.",
VisualBriefingValidationRule.FORMULA_TARGET_INVALID,
$"$.formulas[{formulaIndex}].outputSlotId",
"outputSlotId",
"unique simulation output slot ID");
var simulationControlIds = response.Controls
.Where(control => control.ComponentId == formula.ComponentId)
.Select(control => control.ControlId)
.ToHashSet(StringComparer.Ordinal);
var formulaIssue = ValidateFormulaNode(
formula.Formula,
$"$.formulas[{formulaIndex}].formula",
0,
simulationControlIds);
if (formulaIssue is not null)
return formulaIssue;
}
var simulationWithoutFormula = components.FirstOrDefault(component =>
component.Kind is VisualBriefingComponentKind.SIMULATION &&
response.Formulas.All(formula => formula.ComponentId != component.ComponentId));
if (simulationWithoutFormula is not null)
return Invalid(
"Every simulation requires at least one formula.",
VisualBriefingValidationRule.FORMULA_TARGET_INVALID,
"$.formulas",
expected: "at least one formula for every planned SIMULATION component");
var accessibilityIssue = ValidateComponentTexts(
response.AccessibilityTexts,
VisualBriefingComponentTexts.AccessibilityTextKeys(components),
"accessibilityTexts");
if (accessibilityIssue is not null)
return accessibilityIssue;
var labelIssue = ValidateComponentTexts(
response.VisibleLabels,
VisualBriefingComponentTexts.VisibleLabelKeys(components),
"visibleLabels");
if (labelIssue is not null)
return labelIssue;
return ContainsForbidden(response)
? Invalid(
"Content must not contain HTML, CSS, JavaScript, runtime bindings, or chart-library options.",
VisualBriefingValidationRule.MODEL_MARKUP_PROHIBITED)
: null;
}
internal static VisualBriefingContractIssue? ValidateDesign(
VisualBriefingPlanArtifact plan,
VisualBriefingDesignResponse response)
{
if (response.ContractVersion != VisualBriefingVersions.DESIGN_CONTRACT)
return Invalid(
"The design response uses an unsupported contract version.",
VisualBriefingValidationRule.CONTRACT_VERSION_UNSUPPORTED);
if (!COLOR.IsMatch(response.Tokens.PrimaryColor) ||
!COLOR.IsMatch(response.Tokens.AccentColor) ||
!COLOR.IsMatch(response.Tokens.TextColor) ||
!COLOR.IsMatch(response.Tokens.BackgroundColor) ||
response.Tokens.SpacingScale is < 2 or > 12 ||
response.Tokens.Radius is < 0 or > 32)
return Invalid(
"Design tokens are outside the supported values.",
VisualBriefingValidationRule.LAYOUT_INVALID);
var planned = plan.Sections.SelectMany(section => section.Components)
.Select(component => component.ComponentId)
.ToHashSet(StringComparer.Ordinal);
List<string> references = [];
List<string> nodeIds = [];
var issue = ValidateLayoutNode(response.Layout, references, nodeIds);
if (issue is not null)
return issue;
if (nodeIds.Distinct(StringComparer.Ordinal).Count() != nodeIds.Count ||
nodeIds.Any(planned.Contains))
return Invalid(
"Layout node IDs must be unique and must not collide with component IDs.",
VisualBriefingValidationRule.ID_INVALID);
if (references.Count != planned.Count ||
references.Distinct(StringComparer.Ordinal).Count() != references.Count ||
!references.ToHashSet(StringComparer.Ordinal).SetEquals(planned))
return Invalid(
"The layout must reference every planned component exactly once.",
VisualBriefingValidationRule.LAYOUT_INVALID);
// The caller compiles the validated layout right afterwards and guards that compilation as a
// compiler invariant, see VisualBriefingCompilerInvariant. There is no trial compilation here.
return ContainsForbidden(response)
? Invalid(
"Design must not contain HTML, CSS, JavaScript, runtime bindings, or chart-library options.",
VisualBriefingValidationRule.MODEL_MARKUP_PROHIBITED)
: null;
}
private static VisualBriefingContractIssue? ValidateLayoutNode(
VisualBriefingLayoutNode node,
List<string> references,
List<string> nodeIds)
{
if (!IsUsableId(node.NodeId) || node.Span is < 1 or > 12 || node.Order is < 0 or > 1000)
return Invalid(
"A layout node contains an invalid ID, span, or order.",
VisualBriefingValidationRule.LAYOUT_INVALID);
nodeIds.Add(node.NodeId);
if (node.Kind is VisualBriefingLayoutNodeKind.COMPONENT)
{
if (string.IsNullOrWhiteSpace(node.ComponentId) || node.Children.Count != 0 || node.Columns is not null)
return Invalid(
"Component layout nodes may only contain a component reference.",
VisualBriefingValidationRule.LAYOUT_INVALID);
references.Add(node.ComponentId);
return null;
}
if (node.ComponentId is not null || node.Children.Count == 0)
return Invalid(
"Container layout nodes require children and cannot reference a component.",
VisualBriefingValidationRule.LAYOUT_INVALID);
if (node.Kind is VisualBriefingLayoutNodeKind.GRID &&
(node.Columns is null ||
node.Columns.Mobile is < 1 or > 4 ||
node.Columns.Tablet is < 1 or > 8 ||
node.Columns.Desktop is < 1 or > 12))
return Invalid(
"Grid nodes require valid responsive column counts.",
VisualBriefingValidationRule.LAYOUT_INVALID);
if (node.Kind is not VisualBriefingLayoutNodeKind.GRID && node.Columns is not null)
return Invalid(
"Responsive columns are only valid for grid nodes.",
VisualBriefingValidationRule.LAYOUT_INVALID);
foreach (var child in node.Children)
{
var issue = ValidateLayoutNode(child, references, nodeIds);
if (issue is not null)
return issue;
}
return null;
}
private static bool ContainsForbidden<T>(T value)
{
var json = JsonSerializer.SerializeToElement(value, VisualBriefingJson.Compact);
return ContainsForbiddenElement(json);
}
private static bool ContainsForbiddenElement(JsonElement value)
{
if (value.ValueKind is JsonValueKind.Array)
return value.EnumerateArray().Any(ContainsForbiddenElement);
if (value.ValueKind is JsonValueKind.Object)
return value.EnumerateObject().Any(property =>
property.Name is "html" or "templateHtml" or "css" or "script" or "echarts" ||
ContainsForbiddenElement(property.Value));
if (value.ValueKind is not JsonValueKind.String)
return false;
var text = value.GetString() ?? string.Empty;
return FORBIDDEN_MODEL_TEXT.Any(token => text.Contains(token, StringComparison.OrdinalIgnoreCase)) ||
ScriptAccessRegex().IsMatch(text) ||
HtmlMarkupRegex().IsMatch(text) ||
CssSnippetRegex().IsMatch(text);
}
private static bool UniqueIds(IEnumerable<string> values)
{
var items = values.ToArray();
return items.Length > 0 &&
items.All(value => ID.IsMatch(value)) &&
items.Distinct(StringComparer.Ordinal).Count() == items.Length;
}
private static VisualBriefingContractIssue? ValidateFormulaNode(
VisualBriefingFormulaNode node,
string path,
int depth,
IReadOnlySet<string> controlIds)
{
if (depth > 32)
return Invalid(
"A formula exceeds the maximum supported depth.",
VisualBriefingValidationRule.FORMULA_AST_INVALID,
path,
expected: "formula depth at most 32");
if (depth == 0 && node.FormulaVersion != VisualBriefingVersions.FORMULA)
return Invalid(
"The formula root uses an unsupported version.",
VisualBriefingValidationRule.FORMULA_AST_INVALID,
$"{path}.formulaVersion",
"formulaVersion",
"supported formula version");
if (depth > 0 &&
node.FormulaVersion is not 0 &&
node.FormulaVersion != VisualBriefingVersions.FORMULA)
return Invalid(
"A nested formula node uses an unsupported version.",
VisualBriefingValidationRule.FORMULA_AST_INVALID,
$"{path}.formulaVersion",
"formulaVersion",
"zero or supported formula version");
var hasPath = !string.IsNullOrWhiteSpace(node.Path);
var hasValue = node.Value is not null;
var hasOperation = !string.IsNullOrWhiteSpace(node.Operation);
if (new[] { hasPath, hasValue, hasOperation }.Count(value => value) != 1)
return Invalid(
"Every formula node must contain exactly one node kind.",
VisualBriefingValidationRule.FORMULA_AST_INVALID,
path,
expected: "exactly one of path, value, or op");
if (hasPath)
{
if (node.Arguments is not null)
return Invalid(
"A formula path node must not contain arguments.",
VisualBriefingValidationRule.FORMULA_AST_INVALID,
$"{path}.args",
"args",
"omitted");
const string PREFIX = "interactions.state.";
if (!node.Path!.StartsWith(PREFIX, StringComparison.Ordinal) ||
!controlIds.Contains(node.Path[PREFIX.Length..]))
return Invalid(
"A formula path must reference a control of the same simulation.",
VisualBriefingValidationRule.FORMULA_AST_INVALID,
$"{path}.path",
"path",
"interactions.state.<controlId>");
return null;
}
if (hasValue)
return node.Arguments is null
? null
: Invalid(
"A formula value node must not contain arguments.",
VisualBriefingValidationRule.FORMULA_AST_INVALID,
$"{path}.args",
"args",
"omitted");
HashSet<string> operators = new(StringComparer.Ordinal)
{
"add", "subtract", "multiply", "divide", "power", "eq", "ne", "gt", "gte", "lt", "lte",
"if", "min", "max", "round", "sqrt", "log", "exp",
};
if (!operators.Contains(node.Operation!))
return Invalid(
"A formula uses an unsupported operation.",
VisualBriefingValidationRule.FORMULA_AST_INVALID,
$"{path}.op",
"op",
"supported formula operation");
if (node.Arguments is null)
return Invalid(
"A formula operation requires arguments.",
VisualBriefingValidationRule.FORMULA_AST_INVALID,
$"{path}.args",
"args",
"argument array with valid arity");
var count = node.Arguments.Count;
var validArity = node.Operation switch
{
"sqrt" or "log" or "exp" => count == 1,
"subtract" or "divide" or "power" or "eq" or "ne" or "gt" or "gte" or "lt" or "lte" => count == 2,
"if" => count == 3,
"round" => count is 1 or 2,
_ => count > 0,
};
if (!validArity)
return Invalid(
"A formula operation has an invalid number of arguments.",
VisualBriefingValidationRule.FORMULA_AST_INVALID,
$"{path}.args",
"args",
"argument array with valid arity");
for (var argumentIndex = 0; argumentIndex < node.Arguments.Count; argumentIndex++)
{
var issue = ValidateFormulaNode(
node.Arguments[argumentIndex],
$"{path}.args[{argumentIndex}]",
depth + 1,
controlIds);
if (issue is not null)
return issue;
}
return null;
}
/// <summary>
/// Checks whether every row of a validated table slot starts with a text cell.
/// </summary>
/// <param name="tableData">The validated table slot value.</param>
/// <returns>True when every first cell is a string.</returns>
private static bool HasTextFirstColumn(JsonElement tableData) =>
tableData.ValueKind is JsonValueKind.Object &&
tableData.TryGetProperty("rows", out var rows) &&
rows.ValueKind is JsonValueKind.Array &&
rows.EnumerateArray().All(row =>
row.TryGetProperty("cells", out var cells) &&
cells.ValueKind is JsonValueKind.Array &&
cells.GetArrayLength() > 0 &&
cells[0].ValueKind is JsonValueKind.String);
/// <summary>
/// Checks one component text map against the component IDs that actually consume it. Asking for
/// texts that are never rendered is as much a defect as missing the ones that are.
/// </summary>
/// <param name="texts">The model-supplied map.</param>
/// <param name="requiredKeys">The component IDs that consume this kind of text.</param>
/// <param name="field">The contract field name used in diagnostics.</param>
/// <returns>The contract issue, or null when the map is complete and exact.</returns>
private static VisualBriefingContractIssue? ValidateComponentTexts(
IReadOnlyDictionary<string, string> texts,
IReadOnlyList<string> requiredKeys,
string field)
{
var required = requiredKeys.ToHashSet(StringComparer.Ordinal);
var unknownKey = texts.Keys.FirstOrDefault(key => !required.Contains(key));
if (unknownKey is not null)
return Invalid(
$"The {field} contain an entry for a component that does not use one.",
VisualBriefingValidationRule.ACCESSIBILITY_SET_INVALID,
$"$.{field}.*",
field,
"only component IDs that require this text");
foreach (var key in requiredKeys)
{
if (!texts.TryGetValue(key, out var text))
return Invalid(
$"A required entry is missing from {field}.",
VisualBriefingValidationRule.ACCESSIBILITY_SET_INVALID,
$"$.{field}",
field,
"one entry for every component ID that requires this text");
if (string.IsNullOrWhiteSpace(text))
return Invalid(
$"An entry in {field} must not be empty.",
VisualBriefingValidationRule.ACCESSIBILITY_TEXT_INVALID,
$"$.{field}.{key}",
field,
"non-empty target-language string");
}
return texts.Count == required.Count
? null
: Invalid(
$"The {field} must contain exactly one entry per requiring component.",
VisualBriefingValidationRule.ACCESSIBILITY_SET_INVALID,
$"$.{field}",
field,
"exactly one entry for every component ID that requires this text");
}
private static VisualBriefingContractIssue? ValidateControlState(
VisualBriefingControlSpec control,
int controlIndex)
{
var optionValues = control.Options.Select(option => option.Value).ToArray();
HashSet<string> seenOptions = new(StringComparer.Ordinal);
for (var optionIndex = 0; optionIndex < control.Options.Count; optionIndex++)
{
var option = control.Options[optionIndex];
// Option values are pure data: they are compared against the control state and never
// become element IDs, so they may carry the same text as the data they select:
if (string.IsNullOrWhiteSpace(option.Value) ||
option.Value.Length > MAX_OPTION_VALUE_LENGTH ||
!seenOptions.Add(option.Value))
return Invalid(
"Control option values must be non-empty, short, and unique.",
VisualBriefingValidationRule.CONTROL_STATE_INVALID,
$"$.controls[{controlIndex}].options[{optionIndex}].value",
"value",
"unique non-empty string");
if (string.IsNullOrWhiteSpace(option.Label))
return Invalid(
"Control option labels must not be empty.",
VisualBriefingValidationRule.CONTROL_STATE_INVALID,
$"$.controls[{controlIndex}].options[{optionIndex}].label",
"label",
"non-empty target-language string");
}
if (control.Kind is VisualBriefingControlKind.TAB or
VisualBriefingControlKind.FILTER or
VisualBriefingControlKind.SELECT)
{
if (optionValues.Length == 0)
return Invalid(
"This control kind requires options.",
VisualBriefingValidationRule.CONTROL_STATE_INVALID,
$"$.controls[{controlIndex}].options",
"options",
"non-empty option array");
if (control.InitialValue.ValueKind is not JsonValueKind.String ||
!optionValues.Contains(control.InitialValue.GetString(), StringComparer.Ordinal))
return Invalid(
"The initial control value must select one declared option.",
VisualBriefingValidationRule.CONTROL_STATE_INVALID,
$"$.controls[{controlIndex}].initialValue",
"initialValue",
"string equal to one option value");
return null;
}
if (optionValues.Length != 0)
return Invalid(
"Numeric controls must not declare options.",
VisualBriefingValidationRule.CONTROL_STATE_INVALID,
$"$.controls[{controlIndex}].options",
"options",
"empty array");
return control.InitialValue.ValueKind is JsonValueKind.Number
? null
: Invalid(
"Numeric controls require a numeric initial value.",
VisualBriefingValidationRule.CONTROL_STATE_INVALID,
$"$.controls[{controlIndex}].initialValue",
"initialValue",
"JSON number");
}
private static bool ControlMatchesComponent(
VisualBriefingControlKind control,
VisualBriefingComponentKind component) => component switch
{
VisualBriefingComponentKind.TABS =>
control is VisualBriefingControlKind.TAB,
VisualBriefingComponentKind.SIMULATION =>
control is VisualBriefingControlKind.NUMBER or VisualBriefingControlKind.RANGE or
VisualBriefingControlKind.SELECT,
// FILTER controls are generated from the table data, never supplied by the model:
_ => false,
};
private static string ExpectedControlKinds(VisualBriefingComponentKind component) => component switch
{
VisualBriefingComponentKind.TABS => "TAB",
VisualBriefingComponentKind.SIMULATION => "NUMBER, RANGE, or SELECT",
_ => "no controls",
};
private static string? FindInvalidOrDuplicateId(
IEnumerable<(string Id, string Path)> candidates)
{
HashSet<string> seen = new(StringComparer.Ordinal);
foreach (var candidate in candidates)
{
if (!IsUsableId(candidate.Id) || !seen.Add(candidate.Id))
return candidate.Path;
}
return null;
}
/// <summary>
/// Checks whether an ID is well-formed and free of the reserved AI Studio prefix. Compiled
/// element IDs are derived from these IDs, and the artifact contract reserves the mwai- prefix.
/// </summary>
/// <param name="id">The model-supplied ID.</param>
/// <returns>True when the ID can be used.</returns>
private static bool IsUsableId(string id) =>
ID.IsMatch(id) && !id.StartsWith("mwai-", StringComparison.OrdinalIgnoreCase);
private static int FindDuplicateIndex(IReadOnlyList<string> values)
{
HashSet<string> seen = new(StringComparer.Ordinal);
for (var index = 0; index < values.Count; index++)
{
if (!seen.Add(values[index]))
return index;
}
return -1;
}
private static VisualBriefingContractIssue Invalid(
string issue,
VisualBriefingValidationRule rule = VisualBriefingValidationRule.NONE,
string jsonPath = "$",
string fieldName = "",
string expected = "") =>
new(
VisualBriefingFailureCode.RESPONSE_CONTRACT_INVALID,
issue,
rule,
new()
{
IssueKind = VisualBriefingStructuredResponseIssueKind.SEMANTIC_CONTRACT_INVALID,
JsonPath = jsonPath,
FieldName = fieldName,
// Expected carries a contract shape, never a rule name. The rule is reported
// separately, so an unknown shape stays empty:
Expected = expected,
});
[GeneratedRegex("^[a-z][a-z0-9_-]{0,63}$", RegexOptions.CultureInvariant)]
private static partial Regex IdRegex();
[GeneratedRegex("^#[0-9a-fA-F]{6}$", RegexOptions.CultureInvariant)]
private static partial Regex ColorRegex();
// Matches scripted member access such as document.getElementById( but not a sentence that
// happens to end with the word "document":
[GeneratedRegex(@"\b(?:document|window|globalThis)\.[A-Za-z_$][A-Za-z0-9_$]*\s*[({=\[.]", RegexOptions.CultureInvariant)]
private static partial Regex ScriptAccessRegex();
// Matches real HTML tags only. A generic "<...>" pattern would reject ordinary prose such as
// comparisons or placeholders in angle brackets:
[GeneratedRegex(
@"<\s*/?\s*(?:script|style|iframe|object|embed|link|meta|form|input|button|select|option|template|svg|img|video|audio|canvas|table|thead|tbody|tfoot|tr|td|th|caption|div|span|p|a|ul|ol|li|dl|dt|dd|h[1-6]|section|article|aside|header|footer|main|nav|figure|figcaption|details|summary|small|strong|em|b|i|u|br|hr|label|progress)\b[^>]*>",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex HtmlMarkupRegex();
[GeneratedRegex(@"(?:^|\s)[.#]?[A-Za-z][A-Za-z0-9 _-]*\s*\{[^{}]*:[^{}]*\}", RegexOptions.CultureInvariant)]
private static partial Regex CssSnippetRegex();
}
internal sealed class VisualBriefingChartCompiler
{
internal JsonElement Compile(VisualBriefingChartSpec chart)
{
object series = chart.Kind switch
{
VisualBriefingChartKind.PIE or VisualBriefingChartKind.DONUT =>
chart.Categories.Select((category, index) => new
{
name = category,
value = chart.Series[0].Values[index],
}).ToArray(),
VisualBriefingChartKind.RADAR => chart.Series.Select(item => new
{
name = item.Name,
type = "radar",
data = new[]
{
new
{
value = item.Values,
name = item.Name,
},
},
}).ToArray(),
_ => chart.Series.Select(item => new
{
name = item.Name,
type = SeriesType(chart.Kind),
stack = chart.Kind is VisualBriefingChartKind.STACKED_BAR ? "total" : null,
areaStyle = chart.Kind is VisualBriefingChartKind.AREA ? new { } : null,
data = item.Values,
}).ToArray(),
};
var option = new
{
title = new { text = chart.Title },
tooltip = new { trigger = chart.Kind is VisualBriefingChartKind.PIE or VisualBriefingChartKind.DONUT ? "item" : "axis" },
legend = new { show = true },
xAxis = chart.Kind is VisualBriefingChartKind.PIE or VisualBriefingChartKind.DONUT or VisualBriefingChartKind.RADAR
? null
: new { type = "category", data = chart.Categories },
yAxis = chart.Kind is VisualBriefingChartKind.PIE or VisualBriefingChartKind.DONUT or VisualBriefingChartKind.RADAR
? null
: new { type = "value" },
radar = chart.Kind is VisualBriefingChartKind.RADAR
? new { indicator = chart.Categories.Select(name => new { name }).ToArray() }
: null,
series = chart.Kind is VisualBriefingChartKind.PIE or VisualBriefingChartKind.DONUT
? new[] { new { type = "pie", radius = chart.Kind is VisualBriefingChartKind.DONUT ? new[] { "45%", "70%" } : new[] { "0%", "70%" }, data = series } }
: series,
};
return JsonSerializer.SerializeToElement(option, VisualBriefingJson.Compact);
}
private static string SeriesType(VisualBriefingChartKind kind) => kind switch
{
VisualBriefingChartKind.LINE or VisualBriefingChartKind.AREA => "line",
VisualBriefingChartKind.BAR or VisualBriefingChartKind.STACKED_BAR => "bar",
VisualBriefingChartKind.SCATTER => "scatter",
VisualBriefingChartKind.RADAR => "radar",
_ => "line",
};
}
internal sealed class VisualBriefingInteractionCompiler
{
internal JsonElement Compile(
IReadOnlyList<VisualBriefingControlSpec> controls,
IReadOnlyList<VisualBriefingFormulaSpec> formulas)
{
var state = controls.ToDictionary(
control => control.ControlId,
control => control.InitialValue.Clone(),
StringComparer.Ordinal);
var formulaMap = formulas.ToDictionary(
formula => formula.OutputSlotId,
formula => formula.Formula,
StringComparer.Ordinal);
return JsonSerializer.SerializeToElement(new
{
controls,
state,
formulas = formulaMap,
}, VisualBriefingJson.Compact);
}
internal string CompileMarkup(string componentId, IReadOnlyList<VisualBriefingControlSpec> controls)
{
var builder = new StringBuilder();
foreach (var indexed in controls.Select((control, index) => (Control: control, Index: index))
.Where(item => item.Control.ComponentId == componentId))
{
var control = indexed.Control;
// Controls carry no element ID: nothing references it, and a model-chosen control ID
// could otherwise collide with a layout node ID in the compiled template:
var id = HtmlEncoder.Default.Encode(control.ControlId);
var accessibilityPath = $"accessibility.{HtmlEncoder.Default.Encode(componentId)}";
builder.Append(control.Kind switch
{
VisualBriefingControlKind.SELECT or VisualBriefingControlKind.FILTER =>
$"<select data-mwai-model=\"interactions.state.{id}\" data-mwai-attr-aria-label=\"{accessibilityPath}\"><template data-mwai-each=\"interactions.controls.{indexed.Index}.options\"><option data-mwai-attr-value=\".value\" data-mwai-text=\".label\"></option></template></select>",
VisualBriefingControlKind.RANGE =>
$"<input type=\"range\" data-mwai-model=\"interactions.state.{id}\" data-mwai-attr-aria-label=\"{accessibilityPath}\">",
VisualBriefingControlKind.NUMBER =>
$"<input type=\"number\" data-mwai-model=\"interactions.state.{id}\" data-mwai-attr-aria-label=\"{accessibilityPath}\">",
_ => string.Empty,
});
}
return builder.ToString();
}
internal static string CompileResetMarkup(string componentId) =>
$"<button type=\"button\" data-mwai-reset=\"{HtmlEncoder.Default.Encode(componentId)}\" data-mwai-text=\"labels.reset\"></button>";
}
internal sealed class VisualBriefingLayoutCompiler(
VisualBriefingChartCompiler chartCompiler,
VisualBriefingInteractionCompiler interactionCompiler)
{
internal VisualBriefingCompilationResult Compile(
VisualBriefingPlanArtifact plan,
VisualBriefingContentArtifact content,
VisualBriefingLayoutNode layout,
VisualBriefingDesignTokens tokens)
{
var slots = content.Slots.ToDictionary(item => item.SlotId, item => item.Value.Clone(), StringComparer.Ordinal);
var components = plan.Sections.SelectMany(section => section.Components)
.ToDictionary(item => item.ComponentId, StringComparer.Ordinal);
var charts = content.Charts.ToDictionary(item => item.ComponentId, StringComparer.Ordinal);
var missingSlot = components.Values
.SelectMany(component => component.RequiredSlots)
.FirstOrDefault(slotId => !slots.ContainsKey(slotId));
if (missingSlot is not null)
throw new InvalidDataException("A planned content slot is missing during compilation.");
var missingChart = components.Values
.Where(component => component.Kind is VisualBriefingComponentKind.CHART)
.Select(component => component.ComponentId)
.FirstOrDefault(componentId => !charts.ContainsKey(componentId));
if (missingChart is not null)
throw new InvalidDataException("A planned chart is missing during compilation.");
var chartOptions = content.Charts.ToDictionary(
item => item.ComponentId,
item => chartCompiler.Compile(item),
StringComparer.Ordinal);
var interactions = interactionCompiler.Compile(content.Controls, content.Formulas);
var data = JsonSerializer.SerializeToElement(new
{
slots,
charts = chartOptions,
interactions,
accessibility = content.AccessibilityTexts,
visibleLabels = content.VisibleLabels,
sourceReferences = content.SourceReferences,
labels = new { reset = content.ResetLabel },
}, VisualBriefingJson.Compact);
var html = this.CompileNode(layout, components, content);
var css = CompileCss(tokens, layout);
return new(
data,
html,
css,
VisualBriefingHashing.Compute(html),
VisualBriefingHashing.Compute(css));
}
private string CompileNode(
VisualBriefingLayoutNode node,
IReadOnlyDictionary<string, VisualBriefingPlanComponent> components,
VisualBriefingContentArtifact content)
{
var id = HtmlEncoder.Default.Encode(node.NodeId);
if (node.Kind is VisualBriefingLayoutNodeKind.COMPONENT)
{
if (node.ComponentId is null || !components.TryGetValue(node.ComponentId, out var component))
throw new InvalidDataException("The layout references an unknown component.");
var componentId = HtmlEncoder.Default.Encode(component.ComponentId);
var body = this.CompileComponent(component, content);
var componentClasses = CompileLayoutClasses(
node,
$"mwai-component mwai-{component.Kind.ToString().ToLowerInvariant()}");
return $"<article id=\"{id}\" class=\"{componentClasses}\" data-mwai-region=\"{componentId}\">{body}</article>";
}
var tag = node.Kind is VisualBriefingLayoutNodeKind.SECTION ? "section" : "div";
var kind = node.Kind.ToString().ToLowerInvariant();
var layoutClasses = CompileLayoutClasses(node, $"mwai-layout mwai-{kind}");
var children = string.Concat(node.Children.OrderBy(child => child.Order)
.Select(child => this.CompileNode(child, components, content)));
return $"<{tag} id=\"{id}\" class=\"{layoutClasses}\">{children}</{tag}>";
}
private static string CompileLayoutClasses(VisualBriefingLayoutNode node, string prefix) =>
$"{prefix} mwai-span-{node.Span} mwai-align-{node.Alignment.ToString().ToLowerInvariant()}" +
(node.Emphasized ? " mwai-emphasized" : string.Empty);
private string CompileComponent(
VisualBriefingPlanComponent component,
VisualBriefingContentArtifact content)
{
var componentId = HtmlEncoder.Default.Encode(component.ComponentId);
var slotMarkup = string.Concat(component.RequiredSlots.Select(slotId =>
{
var encoded = HtmlEncoder.Default.Encode(slotId);
return $"<span data-mwai-text=\"slots.{encoded}\"></span>";
}));
var controls = interactionCompiler.CompileMarkup(component.ComponentId, content.Controls);
var formulas = string.Concat(content.Formulas
.Where(formula => formula.ComponentId == component.ComponentId)
.Select(formula =>
$"<span data-mwai-expr=\"interactions.formulas.{HtmlEncoder.Default.Encode(formula.OutputSlotId)}\"></span>"));
var filterControl = content.Controls.FirstOrDefault(control =>
control.ComponentId == component.ComponentId &&
control.Kind is VisualBriefingControlKind.FILTER);
// Rows are filtered by their first cell, so the filter options of a filterable table
// correspond to the values of the table's first column:
var filterAttributes = filterControl is null
? string.Empty
: $" data-mwai-filter=\"$root.interactions.state.{HtmlEncoder.Default.Encode(filterControl.ControlId)}\" data-mwai-filter-value=\".cells.0\"";
var body = component.Kind switch
{
VisualBriefingComponentKind.CHART =>
$"<figure><div role=\"img\" data-mwai-attr-aria-label=\"accessibility.{HtmlEncoder.Default.Encode(component.ComponentId)}\" aria-describedby=\"{componentId}-chart-alt\" data-mwai-chart=\"charts.{HtmlEncoder.Default.Encode(component.ComponentId)}\"></div><figcaption id=\"{componentId}-chart-alt\">{slotMarkup}</figcaption></figure>",
VisualBriefingComponentKind.ASSET =>
$"<figure><img data-mwai-asset=\"{HtmlEncoder.Default.Encode(component.AssetId ?? throw new InvalidDataException("An asset component is missing its asset ID."))}\" data-mwai-attr-alt=\"accessibility.{componentId}\"><figcaption>{slotMarkup}</figcaption></figure>",
VisualBriefingComponentKind.TABLE or VisualBriefingComponentKind.FILTERABLE_TABLE =>
CompileTable(component, componentId, controls, filterAttributes),
VisualBriefingComponentKind.TABS =>
this.CompileTabs(component, content.Controls),
VisualBriefingComponentKind.ACCORDION =>
$"<details><summary><span data-mwai-text=\"visibleLabels.{componentId}\"></span></summary><div>{slotMarkup}</div></details>",
VisualBriefingComponentKind.SIMULATION =>
$"<div class=\"mwai-simulation\">{controls}{slotMarkup}{formulas}{VisualBriefingInteractionCompiler.CompileResetMarkup(component.ComponentId)}</div>",
_ => $"{slotMarkup}{controls}",
};
var references = content.SourceReferences.ContainsKey(component.ComponentId)
? $"<small><template data-mwai-each=\"sourceReferences.{componentId}\"><span data-mwai-text=\".\"></span> </template></small>"
: string.Empty;
return $"{body}{references}";
}
/// <summary>
/// Compiles a table component from its tabular data slot. The first required slot carries the
/// columns and rows, see VisualBriefingSlotTypes; any further slot is rendered as leading text.
/// </summary>
/// <param name="component">The planned table component.</param>
/// <param name="componentId">The encoded component ID.</param>
/// <param name="controls">The compiled control markup of the component.</param>
/// <param name="filterAttributes">The compiled row filter attributes, if any.</param>
/// <returns>The compiled table markup.</returns>
private static string CompileTable(
VisualBriefingPlanComponent component,
string componentId,
string controls,
string filterAttributes)
{
var dataSlot = HtmlEncoder.Default.Encode(component.RequiredSlots[0]);
var leadingText = string.Concat(component.RequiredSlots.Skip(1).Select(slotId =>
$"<span data-mwai-text=\"slots.{HtmlEncoder.Default.Encode(slotId)}\"></span>"));
return $"{leadingText}<div class=\"mwai-table-wrap\">{controls}<table>" +
$"<caption data-mwai-text=\"visibleLabels.{componentId}\"></caption>" +
$"<thead><tr><template data-mwai-each=\"slots.{dataSlot}.columns\"><th scope=\"col\" data-mwai-text=\".\"></th></template></tr></thead>" +
$"<tbody><template data-mwai-each=\"slots.{dataSlot}.rows\"><tr{filterAttributes}><template data-mwai-each=\".cells\"><td data-mwai-text=\".\"></td></template></tr></template></tbody>" +
"</table></div>";
}
private string CompileTabs(
VisualBriefingPlanComponent component,
IReadOnlyList<VisualBriefingControlSpec> controls)
{
var indexedControl = controls.Select((control, index) => (Control: control, Index: index))
.First(item =>
item.Control.ComponentId == component.ComponentId &&
item.Control.Kind is VisualBriefingControlKind.TAB);
var initial = indexedControl.Control.InitialValue.GetString();
var componentId = HtmlEncoder.Default.Encode(component.ComponentId);
var buttons = new StringBuilder();
var panels = new StringBuilder();
for (var index = 0; index < indexedControl.Control.Options.Count; index++)
{
var option = indexedControl.Control.Options[index];
// The panel ID must remain a safe identifier, so it is derived from the option position
// instead of the model-supplied option value:
var panelId = $"{componentId}-tab-{index}";
var selected = string.Equals(option.Value, initial, StringComparison.Ordinal);
buttons.Append(
$"<button type=\"button\" role=\"tab\" aria-controls=\"{panelId}\" aria-selected=\"{selected.ToString().ToLowerInvariant()}\" data-mwai-tab-target=\"{panelId}\" data-mwai-text=\"interactions.controls.{indexedControl.Index}.options.{index}.label\"></button>");
var slotId = component.RequiredSlots[Math.Min(index, component.RequiredSlots.Count - 1)];
panels.Append(
$"<section id=\"{panelId}\" role=\"tabpanel\" data-mwai-tab-panel=\"{panelId}\"{(selected ? string.Empty : " hidden")}><span data-mwai-text=\"slots.{HtmlEncoder.Default.Encode(slotId)}\"></span></section>");
}
return $"<div data-mwai-tabs=\"{componentId}\"><div role=\"tablist\">{buttons}</div>{panels}</div>";
}
private static string CompileCss(
VisualBriefingDesignTokens tokens,
VisualBriefingLayoutNode layout)
{
var density = tokens.Density switch
{
VisualBriefingDensity.COMPACT => 0.75m,
VisualBriefingDensity.SPACIOUS => 1.25m,
_ => 1m,
};
var shadow = tokens.Surface is VisualBriefingSurface.RAISED
? "0 12px 32px rgba(23,32,51,.12)"
: "none";
var surface = tokens.Surface switch
{
VisualBriefingSurface.SUBTLE => "background:color-mix(in srgb,var(--mwai-bg),var(--mwai-primary) 4%);",
VisualBriefingSurface.ACCENT => "border:1px solid var(--mwai-accent);",
_ => string.Empty,
};
var typeScale = tokens.TypographyScale switch
{
VisualBriefingTypographyScale.COMPACT => 0.9m,
VisualBriefingTypographyScale.EDITORIAL => 1.1m,
VisualBriefingTypographyScale.DISPLAY => 1.2m,
_ => 1m,
};
var css = new StringBuilder($$"""
.mwai-layout{--mwai-primary:{{tokens.PrimaryColor}};--mwai-accent:{{tokens.AccentColor}};--mwai-text:{{tokens.TextColor}};--mwai-bg:{{tokens.BackgroundColor}};--mwai-space:{{tokens.SpacingScale}}px;--mwai-radius:{{tokens.Radius}}px;--mwai-density:{{density.ToString(System.Globalization.CultureInfo.InvariantCulture)}};--mwai-type-scale:{{typeScale.ToString(System.Globalization.CultureInfo.InvariantCulture)}};box-sizing:border-box;color:var(--mwai-text);background:var(--mwai-bg);font-size:calc(1rem*var(--mwai-type-scale));gap:calc(var(--mwai-space)*var(--mwai-density)*4);}
.mwai-section,.mwai-stack{display:flex;flex-direction:column;}
.mwai-grid{display:grid;}
.mwai-component{display:flex;flex-direction:column;min-width:0;padding:calc(var(--mwai-space)*var(--mwai-density)*4);border-radius:var(--mwai-radius);box-shadow:{{shadow}};{{surface}}}
.mwai-emphasized{border-inline-start:4px solid var(--mwai-accent);}
.mwai-align-start{align-items:start;}.mwai-align-center{align-items:center;}.mwai-align-end{align-items:end;}.mwai-align-stretch{align-items:stretch;}
.mwai-table-wrap{overflow:auto;}table{border-collapse:collapse;width:100%;}img,canvas{display:block;max-width:100%;height:auto;}
""");
foreach (var grid in EnumerateGridNodes(layout))
{
var id = grid.NodeId;
css.Append($"#{id}{{grid-template-columns:repeat({grid.Columns!.Mobile},minmax(0,1fr));}}");
foreach (var child in grid.Children)
css.Append($"#{child.NodeId}{{grid-column:span {Math.Min(child.Span, grid.Columns.Mobile)};}}");
css.Append($"@media(min-width:48rem){{#{id}{{grid-template-columns:repeat({grid.Columns.Tablet},minmax(0,1fr));}}");
foreach (var child in grid.Children)
css.Append($"#{child.NodeId}{{grid-column:span {Math.Min(child.Span, grid.Columns.Tablet)};}}");
css.Append('}');
css.Append($"@media(min-width:75rem){{#{id}{{grid-template-columns:repeat({grid.Columns.Desktop},minmax(0,1fr));}}");
foreach (var child in grid.Children)
css.Append($"#{child.NodeId}{{grid-column:span {Math.Min(child.Span, grid.Columns.Desktop)};}}");
css.Append('}');
}
return css.ToString();
}
private static IEnumerable<VisualBriefingLayoutNode> EnumerateGridNodes(VisualBriefingLayoutNode node)
{
if (node.Kind is VisualBriefingLayoutNodeKind.GRID)
yield return node;
foreach (var child in node.Children)
foreach (var grid in EnumerateGridNodes(child))
yield return grid;
}
}