From 43e2bcdc09ecf51e86f4467f96ad5aa2bc28b1b6 Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 11 Oct 2026 11:42:33 +0330 Subject: [PATCH 1/2] caption inputs with font tokens #13555 --- .../MarkdownEditor/BitMarkdownEditor.scss | 2 +- .../RichTextEditor/BitRichTextEditor.scss | 2 +- .../Components/Inputs/Rating/BitRating.scss | 4 +- .../Components/Inputs/Toggle/BitToggle.scss | 2 +- .../Inputs/Rating/BitRatingDemo.razor.cs | 2 +- .../SearchBox/BitSearchBoxDemo.razor.cs | 2 +- .../Inputs/Toggle/BitToggleDemo.razor.cs | 2 +- .../BitFieldLabelFontWeightContractTests.cs | 96 +++++++++++++++++++ 8 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.scss index ca89f797972..ea7fe8c28ef 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.scss @@ -504,7 +504,7 @@ $mde-button-active-background: var(--bit-MarkdownEditor-button-active-background cursor: pointer; color: inherit; font-size: $tg-fs-sm; - font-weight: $tg-fw-semibold; + font-weight: $tg-field-label-font-weight; letter-spacing: $tg-ctrl-letter-spacing; padding: spacing(0.75) spacing(1) 0; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/RichTextEditor/BitRichTextEditor.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/RichTextEditor/BitRichTextEditor.scss index 6fcc34e306d..4139de8936a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/RichTextEditor/BitRichTextEditor.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/RichTextEditor/BitRichTextEditor.scss @@ -139,7 +139,7 @@ $rte-rule-color: var(--bit-RichTextEditor-table-border-color, #{$clr-brd-pri}); display: block; color: inherit; font-size: $tg-fs-sm; - font-weight: $tg-fw-semibold; + font-weight: $tg-field-label-font-weight; letter-spacing: $tg-ctrl-letter-spacing; padding: spacing(0.75) spacing(1) spacing(0.5); } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss index 537daf47cd9..9250fc3b1f7 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss @@ -23,7 +23,7 @@ // --bit-Rating-active-scale how much the pressed item dips (default: 0.9; 1 turns it off) // --bit-Rating-label-color text color of the label (default: $clr-fg-pri) // --bit-Rating-label-font-size text size of the label (default: $tg-fs-sm) -// --bit-Rating-label-font-weight text weight of the label (default: $tg-fw-semibold) +// --bit-Rating-label-font-weight text weight of the label (default: $tg-field-label-font-weight) // --bit-Rating-label-gap room between the label and the items, and // between the items and the description (default: spacing(1)) // --bit-Rating-description-color text color of the description (default: $clr-fg-sec) @@ -161,7 +161,7 @@ $rtg-private-properties: clr, clr-hover, clr-active, clr-focus, size, lbl-size, letter-spacing: $tg-ctrl-letter-spacing; color: var(--bit-Rating-label-color, #{$clr-fg-pri}); font-size: var(--bit-rtg-lbl-size, var(--bit-Rating-label-font-size, #{$tg-fs-sm})); - font-weight: var(--bit-Rating-label-font-weight, #{$tg-fw-semibold}); + font-weight: var(--bit-Rating-label-font-weight, #{$tg-field-label-font-weight}); } // The hint that sits under the items, pointed at by aria-describedby rather than read as part of the diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Toggle/BitToggle.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Toggle/BitToggle.scss index bf58843e91a..9ef2f0c28dc 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Toggle/BitToggle.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Toggle/BitToggle.scss @@ -48,7 +48,7 @@ // --bit-Toggle-content-padding inset of the track content from the ends of // the track (default: spacing(0.375)) // --bit-Toggle-label-font-size the label (default: $tg-fs-sm) -// --bit-Toggle-label-font-weight the label (default: $tg-fw-semibold) +// --bit-Toggle-label-font-weight the label (default: $tg-field-label-font-weight) // --bit-Toggle-text-font-size the state text (default: inherited from the page) // --bit-Toggle-description-font-size the description (default: $tg-fs-xs) // --bit-Toggle-error-font-size the error message line (default: $tg-fs-xs) diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs index c39db5a61bc..2b4e6f948e6 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs @@ -554,7 +554,7 @@ public partial class BitRatingDemo new() { Name = "--bit-Rating-label-font-weight", - DefaultValue = "--bit-tpg-fw-semibold", + DefaultValue = "--bit-tpg-field-label-font-weight", Description = "Text weight of the label.", }, new() diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/SearchBox/BitSearchBoxDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/SearchBox/BitSearchBoxDemo.razor.cs index 77b09612666..16ef30eb8a2 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/SearchBox/BitSearchBoxDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/SearchBox/BitSearchBoxDemo.razor.cs @@ -730,7 +730,7 @@ public partial class BitSearchBoxDemo new() { Name = "--bit-SearchBox-label-font-weight", - DefaultValue = "$tg-field-label-font-weight", + DefaultValue = "--bit-tpg-field-label-font-weight", Description = "Weight of the label.", }, new() diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Toggle/BitToggleDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Toggle/BitToggleDemo.razor.cs index a55fd9247de..cf6fef78c38 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Toggle/BitToggleDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Toggle/BitToggleDemo.razor.cs @@ -709,7 +709,7 @@ public partial class BitToggleDemo new() { Name = "--bit-Toggle-label-font-weight", - DefaultValue = "$tg-field-label-font-weight", + DefaultValue = "--bit-tpg-field-label-font-weight", Description = "The label.", }, new() diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs new file mode 100644 index 00000000000..c3d9cde833d --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Utils.Theme; + +/// +/// Pins that every caption naming a form field takes its weight from the theme's field-label token +/// ($tg-field-label-font-weight), the one BitLabel reads too. +/// +/// +/// Fluent captions a field semibold, while Fluent 2, Material and Cupertino caption it regular, so a caption that +/// reads the semibold step itself stays semibold under those presets beside every other input and BitLabel. Reading +/// the token through the theme also keeps a caption following a FontWeights override of a BitThemeProvider, +/// since the alias is re-declared for the overlay (the typography group). A new input's caption goes in the +/// list below; the sweep catches one that missed the token and is still written as the Fluent semibold. +/// +[TestClass] +public sealed class BitFieldLabelFontWeightContractTests +{ + private static readonly Regex FieldLabelWeight = new( + @"font-weight:[^;]*\$tg-field-label-font-weight", + RegexOptions.Compiled); + + private static readonly Regex SemiboldWeight = new( + @"font-weight:[^;]*\$tg-fw-semibold", + RegexOptions.Compiled); + + // The caption of a component, as its stylesheet names it: .bit--lbl. + private static readonly Regex CaptionSelector = new( + @"^\.bit-[a-z]+-lbl$", + RegexOptions.Compiled); + + [TestMethod, + DataRow("Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.scss", ".bit-chg-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/CircularTimePicker/BitCircularTimePicker.scss", ".bit-ctp-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/ColorPicker/BitColorPicker.scss", ".bit-clp-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/DatePicker/BitDatePicker.scss", ".bit-dtp-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/DateRangePicker/BitDateRangePicker.scss", ".bit-dtrp-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.scss", ".bit-drp-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/NumberField/BitNumberField.scss", ".bit-nfl-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.scss", ".bit-otp-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss", ".bit-rtg-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/SearchBox/BitSearchBox.scss", ".bit-srb-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/Slider/BitSlider.scss", ".bit-sld-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.scss", ".bit-tgi-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/TextField/BitTextField.scss", ".bit-tfl-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePicker.scss", ".bit-tpc-lbl"), + DataRow("Bit.BlazorUI/Components/Inputs/Toggle/BitToggle.scss", ".bit-tgl-lbl"), + DataRow("Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.scss", ".bit-bfc-field-label"), + DataRow("Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.scss", ".bit-mde-lbl"), + DataRow("Bit.BlazorUI.Extras/Components/PhoneInput/BitPhoneInput.scss", ".bit-phi-lbl"), + DataRow("Bit.BlazorUI.Extras/Components/PhoneInput/BitPhoneInput.scss", ".bit-phi-rlb"), + DataRow("Bit.BlazorUI.Extras/Components/RichTextEditor/BitRichTextEditor.scss", ".bit-rte-lbl")] + public void FieldCaptionShouldTakeItsWeightFromTheFieldLabelToken(string path, string selector) + { + var stylesheet = SourceFiles.Read(path.Split('/')); + + var declarations = SourceFiles.GetScssDeclarations(stylesheet, $"\n{selector} {{", $"{path} has no {selector} rule."); + + Assert.IsTrue(FieldLabelWeight.IsMatch(declarations), $"{selector} of {path} does not read $tg-field-label-font-weight."); + Assert.IsFalse(SemiboldWeight.IsMatch(declarations), $"{selector} of {path} still reads $tg-fw-semibold."); + } + + [TestMethod] + public void NoComponentCaptionShouldReadTheSemiboldStep() + { + var offenders = new List(); + + foreach (var file in EnumerateComponentStylesheets()) + { + var stylesheet = SourceFiles.StripScssComments(SourceFiles.ReadFullPath(file)); + + foreach (var rule in SourceFiles.GetScssRules(stylesheet)) + { + if (rule.Ancestors.Count > 0 || CaptionSelector.IsMatch(rule.Header) is false) continue; + + var declarations = SourceFiles.GetScssDeclarations(stylesheet[rule.Index..], rule.Header); + + if (SemiboldWeight.IsMatch(declarations)) + { + offenders.Add($"{Path.GetRelativePath(SourceFiles.Root, file)}: {rule.Header}"); + } + } + } + + Assert.AreEqual(0, offenders.Count, + "These captions read $tg-fw-semibold rather than $tg-field-label-font-weight:\n" + string.Join("\n", offenders)); + } + + private static IEnumerable EnumerateComponentStylesheets() + => new[] { "Bit.BlazorUI", "Bit.BlazorUI.Extras" } + .SelectMany(project => Directory.EnumerateFiles(SourceFiles.GetDirectory(project, "Components"), "*.scss", SearchOption.AllDirectories)); +} From abcf22e2bb3a3fad66b4e524864041f653f20c03 Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 11 Oct 2026 13:46:52 +0330 Subject: [PATCH 2/2] code review --- .../SearchBox/BitSearchBoxDemo.razor.cs | 2 +- .../Inputs/Toggle/BitToggleDemo.razor.cs | 2 +- .../BitFieldLabelFontWeightContractTests.cs | 248 ++++++++++++++---- 3 files changed, 205 insertions(+), 47 deletions(-) diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/SearchBox/BitSearchBoxDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/SearchBox/BitSearchBoxDemo.razor.cs index 16ef30eb8a2..77b09612666 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/SearchBox/BitSearchBoxDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/SearchBox/BitSearchBoxDemo.razor.cs @@ -730,7 +730,7 @@ public partial class BitSearchBoxDemo new() { Name = "--bit-SearchBox-label-font-weight", - DefaultValue = "--bit-tpg-field-label-font-weight", + DefaultValue = "$tg-field-label-font-weight", Description = "Weight of the label.", }, new() diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Toggle/BitToggleDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Toggle/BitToggleDemo.razor.cs index cf6fef78c38..a55fd9247de 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Toggle/BitToggleDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Toggle/BitToggleDemo.razor.cs @@ -709,7 +709,7 @@ public partial class BitToggleDemo new() { Name = "--bit-Toggle-label-font-weight", - DefaultValue = "--bit-tpg-field-label-font-weight", + DefaultValue = "$tg-field-label-font-weight", Description = "The label.", }, new() diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs index c3d9cde833d..347d6178c40 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -8,86 +9,243 @@ namespace Bit.BlazorUI.Tests.Utils.Theme; /// /// Pins that every caption naming a form field takes its weight from the theme's field-label token -/// ($tg-field-label-font-weight), the one BitLabel reads too. +/// ($tg-field-label-font-weight), the one BitLabel reads too, and nothing else. /// /// /// Fluent captions a field semibold, while Fluent 2, Material and Cupertino caption it regular, so a caption that -/// reads the semibold step itself stays semibold under those presets beside every other input and BitLabel. Reading -/// the token through the theme also keeps a caption following a FontWeights override of a BitThemeProvider, -/// since the alias is re-declared for the overlay (the typography group). A new input's caption goes in the -/// list below; the sweep catches one that missed the token and is still written as the Fluent semibold. +/// reads any weight of its own - the semibold step, another step, $tg-font-weight or a literal - stays that +/// weight under those presets beside every other input and BitLabel. Reading the token through the theme also keeps +/// a caption following a FontWeights override of a BitThemeProvider, since the alias is re-declared for the +/// overlay (the typography group). +/// +/// Every class a component stylesheet names like a caption (a part ending in -lbl, -rlb or +/// -label) is classified below, either as a field's caption or as a label that is not one (a button's text, a +/// tag's, a pager's), so a new input's caption cannot slip past the contract unlisted. Every rule that +/// targets a field caption is read - a grouped selector, a nested one, one inside @media - and each of its +/// font-weight declarations has to read the token. +/// /// [TestClass] public sealed class BitFieldLabelFontWeightContractTests { - private static readonly Regex FieldLabelWeight = new( - @"font-weight:[^;]*\$tg-field-label-font-weight", + private const string FieldLabelToken = "$tg-field-label-font-weight"; + + // The captions that name a form field, with the stylesheet each is styled in. + private static readonly (string Caption, string Path)[] FieldCaptions = + [ + (".bit-chg-lbl", "Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.scss"), + (".bit-ctp-lbl", "Bit.BlazorUI/Components/Inputs/CircularTimePicker/BitCircularTimePicker.scss"), + (".bit-clp-lbl", "Bit.BlazorUI/Components/Inputs/ColorPicker/BitColorPicker.scss"), + (".bit-dtp-lbl", "Bit.BlazorUI/Components/Inputs/DatePicker/BitDatePicker.scss"), + (".bit-dtrp-lbl", "Bit.BlazorUI/Components/Inputs/DateRangePicker/BitDateRangePicker.scss"), + (".bit-drp-lbl", "Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.scss"), + (".bit-nfl-lbl", "Bit.BlazorUI/Components/Inputs/NumberField/BitNumberField.scss"), + (".bit-otp-lbl", "Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.scss"), + (".bit-rtg-lbl", "Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss"), + (".bit-srb-lbl", "Bit.BlazorUI/Components/Inputs/SearchBox/BitSearchBox.scss"), + (".bit-sld-lbl", "Bit.BlazorUI/Components/Inputs/Slider/BitSlider.scss"), + (".bit-tgi-lbl", "Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.scss"), + (".bit-tfl-lbl", "Bit.BlazorUI/Components/Inputs/TextField/BitTextField.scss"), + (".bit-tpc-lbl", "Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePicker.scss"), + (".bit-tgl-lbl", "Bit.BlazorUI/Components/Inputs/Toggle/BitToggle.scss"), + (".bit-bfc-field-label", "Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.scss"), + (".bit-mde-lbl", "Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.scss"), + (".bit-phi-lbl", "Bit.BlazorUI.Extras/Components/PhoneInput/BitPhoneInput.scss"), + (".bit-phi-rlb", "Bit.BlazorUI.Extras/Components/PhoneInput/BitPhoneInput.scss"), + (".bit-rte-lbl", "Bit.BlazorUI.Extras/Components/RichTextEditor/BitRichTextEditor.scss"), + ]; + + // The classes named like a caption that caption no field, so their weight is their own component's decision. + private static readonly HashSet OtherLabels = + [ + ".bit-acb-lbl", // the text of an action button + ".bit-btn-lbl", // the loading text of a button + ".bit-tgb-lbl", // the loading text of a toggle button + ".bit-chb-lbl", // the clickable row of a checkbox: the box and its text, read at the body weight + ".bit-fin-lbl", // the browse button of a file input + ".bit-upl-lbl", // the browse button / drop area of a file upload + ".bit-ldn-lbl", // the text under a loading indicator + ".bit-prb-lbl", // the text of a progress bar + ".bit-pgn-lbl", // the page text of a pagination + ".bit-tag-lbl", // the text of a tag + ".bit-dtg-chooser-label", // a checkbox row of a data grid's column chooser + ".bit-pdv-page-label", // the page number of a pdf viewer + ".bit-pdv-thumb-label", // the number under a pdf viewer's thumbnail + ".bit-bfc-bg-event-label", // the title of a full calendar's background event + ".bit-bfc-dropdown-label", // the heading of a section of a full calendar's settings menu + ".bit-bfc-dtp-month-label", // the month a full calendar's date picker shows + ".bit-bfc-event-detail-label", // the term of a row of a full calendar's event details + ".bit-bfc-repeat-dates-label", // the heading of the skipped / added dates of a repeating event + ".bit-bfc-time-slot-label", // the time of a full calendar's slot + ".bit-bfc-timeline-label", // the time on a full calendar's timeline + ".bit-bfc-tl-time-cell-label", // the time of a full calendar's timeline cell + ]; + + // A class named like a caption: a part of a component whose name ends in lbl, rlb or label. The name must end + // there: --bit-btn-lbl-fontsize is no class, .bit-tfl-lbl-x is another part. + private static readonly Regex CaptionClass = new( + @"\.bit-[a-z0-9]+(?:-[a-z0-9]+)*-(?:lbl|rlb|label)(?![-\w])", RegexOptions.Compiled); - private static readonly Regex SemiboldWeight = new( - @"font-weight:[^;]*\$tg-fw-semibold", + // The font-weight property itself - never a custom property whose name ends in font-weight - with its value, + // interpolations (#{...}) included. + private static readonly Regex FontWeight = new( + @"(?(?:#\{[^}]*\}|[^;{}])+)", RegexOptions.Compiled); - // The caption of a component, as its stylesheet names it: .bit--lbl. - private static readonly Regex CaptionSelector = new( - @"^\.bit-[a-z]+-lbl$", + // A weight of its own, once the token is taken out of the value: a typography step or the base weight, a number + // or a keyword. + private static readonly Regex OtherWeight = new( + @"\$tg-(?:fw-|font-weight)|(? c.Caption).ToHashSet(); + var found = new Dictionary(); + + foreach (var file in EnumerateComponentStylesheets()) + { + var stylesheet = SourceFiles.StripScssComments(SourceFiles.ReadFullPath(file)); + + foreach (var rule in SourceFiles.GetScssRules(stylesheet)) + { + foreach (Match match in CaptionClass.Matches(rule.Header)) + { + found.TryAdd(match.Value, Path.GetRelativePath(SourceFiles.Root, file)); + } + } + } - var declarations = SourceFiles.GetScssDeclarations(stylesheet, $"\n{selector} {{", $"{path} has no {selector} rule."); + var unclassified = found.Where(c => fieldCaptions.Contains(c.Key) is false && OtherLabels.Contains(c.Key) is false) + .Select(c => $"{c.Value}: {c.Key}") + .ToList(); - Assert.IsTrue(FieldLabelWeight.IsMatch(declarations), $"{selector} of {path} does not read $tg-field-label-font-weight."); - Assert.IsFalse(SemiboldWeight.IsMatch(declarations), $"{selector} of {path} still reads $tg-fw-semibold."); + Assert.AreEqual(0, unclassified.Count, + "These classes are named like a caption but are in neither list of this test - a field's caption goes in " + + "FieldCaptions, any other label in OtherLabels:\n" + string.Join("\n", unclassified)); + + var stale = fieldCaptions.Concat(OtherLabels).Where(c => found.ContainsKey(c) is false).ToList(); + + Assert.AreEqual(0, stale.Count, "No component stylesheet styles these listed classes any more:\n" + string.Join("\n", stale)); } [TestMethod] - public void NoComponentCaptionShouldReadTheSemiboldStep() + public void FieldCaptionShouldTakeItsWeightFromTheFieldLabelToken() { var offenders = new List(); - foreach (var file in EnumerateComponentStylesheets()) + foreach (var (caption, path) in FieldCaptions) { - var stylesheet = SourceFiles.StripScssComments(SourceFiles.ReadFullPath(file)); + var stylesheet = SourceFiles.StripScssComments(SourceFiles.Read(path.Split('/'))); + var weighted = 0; foreach (var rule in SourceFiles.GetScssRules(stylesheet)) { - if (rule.Ancestors.Count > 0 || CaptionSelector.IsMatch(rule.Header) is false) continue; + if (ResolveSelectors(rule).Any(selector => Targets(selector, caption)) is false) continue; var declarations = SourceFiles.GetScssDeclarations(stylesheet[rule.Index..], rule.Header); - if (SemiboldWeight.IsMatch(declarations)) + foreach (Match weight in FontWeight.Matches(declarations)) { - offenders.Add($"{Path.GetRelativePath(SourceFiles.Root, file)}: {rule.Header}"); + weighted++; + + var value = weight.Groups["value"].Value.Trim(); + + if (value.Contains(FieldLabelToken) is false || OtherWeight.IsMatch(value.Replace(FieldLabelToken, ""))) + { + offenders.Add($"{path}: {caption} in \"{rule.Header}\" has font-weight: {value}"); + } } } + + if (weighted == 0) + { + offenders.Add($"{path}: no rule sets the font-weight of {caption}"); + } } Assert.AreEqual(0, offenders.Count, - "These captions read $tg-fw-semibold rather than $tg-field-label-font-weight:\n" + string.Join("\n", offenders)); + $"Every font-weight of a field caption reads {FieldLabelToken} and nothing else:\n" + string.Join("\n", offenders)); + } + + // Whether the selector's subject - its last compound, less any :not() - carries the caption class. A rule whose + // subject is a descendant of the caption (.bit-chb-lbl .bit-chb-box) or its pseudo-element (the required + // asterisk of ::after) styles something else. + private static bool Targets(string selector, string caption) + { + var subject = LastCompound(selector); + + if (subject.Contains("::")) return false; + + subject = Regex.Replace(subject, @":not\([^()]*\)", ""); + + return Regex.IsMatch(subject, Regex.Escape(caption) + @"(?![-\w])"); + } + + // The selectors a rule matches, its ancestors' folded in the way Sass nests them: & stands for the parent, and a + // selector without one is a descendant of it. An at-rule (@media, @supports, @include, @mixin) adds no selector. + private static IEnumerable ResolveSelectors(SourceFiles.ScssRule rule) + { + if (rule.Header.StartsWith('@')) return []; + + IEnumerable resolved = [""]; + + foreach (var header in rule.Ancestors.Append(rule.Header)) + { + if (header.StartsWith('@')) continue; + + var parents = resolved.ToList(); + + resolved = SplitSelectorList(header).SelectMany(child => parents.Select(parent => + parent.Length == 0 ? child.Replace("&", "") + : child.Contains('&') ? child.Replace("&", parent) + : $"{parent} {child}")).ToList(); + } + + return resolved; + } + + private static IEnumerable SplitSelectorList(string header) + { + var depth = 0; + var current = new StringBuilder(); + + foreach (var c in header) + { + if (c is '(' or '[') depth++; + else if (c is ')' or ']') depth--; + + if (c == ',' && depth == 0) + { + yield return current.ToString().Trim(); + current.Clear(); + } + else + { + current.Append(c); + } + } + + yield return current.ToString().Trim(); + } + + private static string LastCompound(string selector) + { + var depth = 0; + var start = 0; + + for (var i = 0; i < selector.Length; i++) + { + var c = selector[i]; + + if (c is '(' or '[') depth++; + else if (c is ')' or ']') depth--; + else if (depth == 0 && (char.IsWhiteSpace(c) || c is '>' or '~' or '+')) start = i + 1; + } + + return selector[start..]; } private static IEnumerable EnumerateComponentStylesheets()