///|
/// Policy used when checking a type scale for readable production output.
pub struct TypographyPolicy {
min_size_px : Int
max_size_px : Int
min_line_height_px : Int
min_line_ratio_x100 : Int
max_line_ratio_x100 : Int
require_fallback : Bool
require_monotonic_scale : Bool
} derive(Debug, Eq)
///|
pub fn TypographyPolicy::default() -> TypographyPolicy {
{
min_size_px: 10,
max_size_px: 160,
min_line_height_px: 12,
min_line_ratio_x100: 110,
max_line_ratio_x100: 220,
require_fallback: true,
require_monotonic_scale: true,
}
}
///|
pub fn TypographyPolicy::strict() -> TypographyPolicy {
{
min_size_px: 12,
max_size_px: 128,
min_line_height_px: 14,
min_line_ratio_x100: 115,
max_line_ratio_x100: 200,
require_fallback: true,
require_monotonic_scale: true,
}
}
///|
pub fn TypographyPolicy::with_size_bounds(
self : TypographyPolicy,
minimum : Int,
maximum : Int,
) -> TypographyPolicy {
{
..self,
min_size_px: if minimum < 1 {
1
} else {
minimum
},
max_size_px: if maximum < minimum {
minimum
} else {
maximum
},
}
}
///|
pub fn TypographyPolicy::with_line_ratio_bounds(
self : TypographyPolicy,
minimum_x100 : Int,
maximum_x100 : Int,
) -> TypographyPolicy {
{
..self,
min_line_ratio_x100: if minimum_x100 < 100 {
100
} else {
minimum_x100
},
max_line_ratio_x100: if maximum_x100 < minimum_x100 {
minimum_x100
} else {
maximum_x100
},
}
}
///|
pub fn TypographyPolicy::with_fallback_requirement(
self : TypographyPolicy,
required : Bool,
) -> TypographyPolicy {
{ ..self, require_fallback: required }
}
///|
pub fn TypographyPolicy::with_monotonic_requirement(
self : TypographyPolicy,
required : Bool,
) -> TypographyPolicy {
{ ..self, require_monotonic_scale: required }
}
///|
pub fn TypeStep::ratio_x100(self : TypeStep) -> Int {
if self.size_px <= 0 {
0
} else {
self.line_height_px * 100 / self.size_px
}
}
///|
pub fn TypeStep::ratio_to(self : TypeStep, other : TypeStep) -> Int {
if other.size_px <= 0 {
0
} else {
self.size_px * 100 / other.size_px
}
}
///|
pub fn TypeStep::is_valid(self : TypeStep, policy : TypographyPolicy) -> Bool {
self.name.trim().to_owned() != "" &&
self.size_px >= policy.min_size_px &&
self.size_px <= policy.max_size_px &&
self.line_height_px >= policy.min_line_height_px &&
self.ratio_x100() >= policy.min_line_ratio_x100 &&
self.ratio_x100() <= policy.max_line_ratio_x100
}
///|
pub fn TypeStep::is_accessible(self : TypeStep) -> Bool {
self.ratio_x100() >= 115
}
///|
pub fn TypeStep::line_gap(self : TypeStep) -> Int {
self.line_height_px - self.size_px
}
///|
pub fn TypeStep::css_size(self : TypeStep) -> String {
self.size_px.to_string() + "px"
}
///|
pub fn TypeStep::css_line_height(self : TypeStep) -> String {
self.line_height_px.to_string() + "px"
}
///|
pub fn TypeStep::css_variable_name(self : TypeStep) -> String {
"--brand-type-" + normalize_identifier(self.name)
}
///|
pub fn TypeStep::description(self : TypeStep) -> String {
self.name +
": " +
self.css_size() +
"/" +
self.css_line_height() +
" (" +
self.ratio_x100().to_string() +
"%)"
}
///|
pub fn FontToken::fallback_chain(self : FontToken) -> Array[String] {
let values : Array[String] = [self.name]
for value in self.fallback.split(",") {
let clean = value.trim().to_owned()
if clean != "" {
values.push(clean)
}
}
values
}
///|
pub fn FontToken::has_fallback(self : FontToken) -> Bool {
self.fallback_chain().length() >= 2
}
///|
pub fn FontToken::css_family(self : FontToken) -> String {
"'" + self.name + "', " + self.fallback
}
///|
pub fn FontToken::css_variable_name(self : FontToken) -> String {
"--brand-font-" + normalize_identifier(self.name)
}
///|
pub fn FontToken::usage_label(self : FontToken) -> String {
if self.usage.trim().to_owned() == "" {
"unassigned"
} else {
self.usage
}
}
///|
pub fn Typography::audit_rhythm(
self : Typography,
policy : TypographyPolicy,
) -> AuditReport {
let findings : Array[Finding] = []
if self.families.length() == 0 {
findings.push(Finding::{
severity: Error,
path: "typography.families",
message: "at least one font family is required",
})
}
for index, family in self.families {
if family.name.trim().to_owned() == "" {
findings.push(Finding::{
severity: Error,
path: "typography.families[" + index.to_string() + "]",
message: "font family name cannot be empty",
})
}
if policy.require_fallback && !family.has_fallback() {
findings.push(Finding::{
severity: Error,
path: "typography.families[" + index.to_string() + "].fallback",
message: "font family must declare a fallback chain",
})
}
}
if self.scale.length() == 0 {
findings.push(Finding::{
severity: Error,
path: "typography.scale",
message: "at least one type step is required",
})
}
for index, step in self.scale {
if !step.is_valid(policy) {
findings.push(Finding::{
severity: Error,
path: "typography.scale[" + index.to_string() + "]",
message: "type step is outside the configured rhythm bounds",
})
}
if step.name.trim().to_owned() == "" {
findings.push(Finding::{
severity: Error,
path: "typography.scale[" + index.to_string() + "].name",
message: "type step name cannot be empty",
})
}
}
if policy.require_monotonic_scale {
for index in 0.. 0 && self.scale[index].size_px < self.scale[index - 1].size_px {
findings.push(Finding::{
severity: Error,
path: "typography.scale[" + index.to_string() + "]",
message: "type scale must be monotonic by size",
})
}
}
}
AuditReport::from_findings(findings)
}
///|
pub fn Typography::is_valid(self : Typography) -> Bool {
!self.audit_rhythm(TypographyPolicy::default()).has_errors()
}
///|
pub fn Typography::css_variable_map(self : Typography) -> String {
let lines : Array[String] = [":root {"]
for family in self.families {
lines.push(
" " + family.css_variable_name() + ": " + family.css_family() + ";",
)
}
for step in self.scale {
lines.push(" " + step.css_variable_name() + ": " + step.css_size() + ";")
lines.push(
" " + step.css_variable_name() + "-line: " + step.css_line_height() + ";",
)
}
lines.push("}")
lines.join("\n")
}
///|
pub fn Typography::font_stack(self : Typography, name : String) -> String {
for family in self.families {
if family.name == name {
return family.css_family()
}
}
"system-ui, sans-serif"
}
///|
pub fn Typography::step_named(self : Typography, name : String) -> TypeStep? {
for step in self.scale {
if step.name == name {
return Some(step)
}
}
None
}
///|
pub fn Typography::scale_names(self : Typography) -> Array[String] {
self.scale.map(fn(step) { step.name })
}
///|
pub fn Typography::largest_step(self : Typography) -> TypeStep? {
if self.scale.length() == 0 {
None
} else {
let mut result = self.scale[0]
for step in self.scale {
if step.size_px > result.size_px {
result = step
}
}
Some(result)
}
}
///|
pub fn Typography::smallest_step(self : Typography) -> TypeStep? {
if self.scale.length() == 0 {
None
} else {
let mut result = self.scale[0]
for step in self.scale {
if step.size_px < result.size_px {
result = step
}
}
Some(result)
}
}
///|
pub fn Typography::total_rhythm_units(self : Typography) -> Int {
self.scale.fold(init=0, fn(total, step) { total + step.line_height_px })
}
///|
pub fn Typography::average_line_ratio(self : Typography) -> Int {
if self.scale.length() == 0 {
0
} else {
self.scale.fold(init=0, fn(total, step) { total + step.ratio_x100() }) /
self.scale.length()
}
}
///|
pub fn Typography::to_scale_markdown(self : Typography) -> String {
let lines : Array[String] = [
"| Name | Size | Line height | Ratio |", "| --- | ---: | ---: | ---: |",
]
for step in self.scale {
lines.push(
"| " +
step.name +
" | " +
step.size_px.to_string() +
"px | " +
step.line_height_px.to_string() +
"px | " +
step.ratio_x100().to_string() +
"% |",
)
}
lines.join("\n")
}
///|
pub fn Typography::family_names(self : Typography) -> Array[String] {
self.families.map(fn(family) { family.name })
}
///|
pub fn Typography::has_step(self : Typography, name : String) -> Bool {
self.step_named(name) is Some(_)
}
///|
pub fn Typography::accessible_step_count(self : Typography) -> Int {
self.scale.filter(fn(step) { step.is_accessible() }).length()
}
///|
pub fn Typography::family_for_usage(
self : Typography,
usage : String,
) -> FontToken? {
for family in self.families {
if family.usage.contains(usage) {
return Some(family)
}
}
None
}