diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/RichTextEditor/BitRichTextEditor.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/RichTextEditor/BitRichTextEditor.scss
index 6fcc34e306..4139de8936 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 537daf47cd..9250fc3b1f 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 392a310df3..3c7eca44a9 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 c39db5a61b..2b4e6f948e 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/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs
new file mode 100644
index 0000000000..347d6178c4
--- /dev/null
+++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs
@@ -0,0 +1,254 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+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, and nothing else.
+///
+///
+/// Fluent captions a field semibold, while Fluent 2, Material and Cupertino caption it regular, so a caption that
+/// 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 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);
+
+ // 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);
+
+ // 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 unclassified = found.Where(c => fieldCaptions.Contains(c.Key) is false && OtherLabels.Contains(c.Key) is false)
+ .Select(c => $"{c.Value}: {c.Key}")
+ .ToList();
+
+ 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 FieldCaptionShouldTakeItsWeightFromTheFieldLabelToken()
+ {
+ var offenders = new List();
+
+ foreach (var (caption, path) in FieldCaptions)
+ {
+ var stylesheet = SourceFiles.StripScssComments(SourceFiles.Read(path.Split('/')));
+ var weighted = 0;
+
+ foreach (var rule in SourceFiles.GetScssRules(stylesheet))
+ {
+ if (ResolveSelectors(rule).Any(selector => Targets(selector, caption)) is false) continue;
+
+ var declarations = SourceFiles.GetScssDeclarations(stylesheet[rule.Index..], rule.Header);
+
+ foreach (Match weight in FontWeight.Matches(declarations))
+ {
+ 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,
+ $"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()
+ => new[] { "Bit.BlazorUI", "Bit.BlazorUI.Extras" }
+ .SelectMany(project => Directory.EnumerateFiles(SourceFiles.GetDirectory(project, "Components"), "*.scss", SearchOption.AllDirectories));
+}