use super::types::{Finding, Severity};
const INTERACTIVE_PREFIXES: &[&str] = &[
"fjui-btn",
"fjui-input",
"fjui-select",
"fjui-textarea",
"fjui-sidebar__nav-item",
"fjui-menu-item",
"fjui-tab",
"fjui-table__row",
"fjui-kanban__card",
"fjui-action-card",
"fjui-tile",
"fjui-notification-dropdown__trigger",
"fjui-toast__close",
];
const NON_INTERACTIVE_PREFIXES: &[&str] = &[
"fjui-card",
"fjui-badge",
"fjui-alert",
"fjui-stat-card",
"fjui-header",
"fjui-table__header",
"fjui-table__cell",
"fjui-table__body",
"fjui-stat-card__value",
"fjui-sidebar__group-label",
];
const COLOR_PROPERTIES: &[&str] = &[
"color",
"background",
"background-color",
"border-color",
"box-shadow",
"fill",
"stroke",
"outline-color",
"caret-color",
"text-decoration-color",
"accent-color",
];
struct RuleBlock {
selector: String,
body: String,
}
fn extract_components_layer(css: &str) -> &str {
let marker = "@layer components";
let start = match css.find(marker) {
Some(s) => s,
None => return "",
};
let after = &css[start..];
let brace_start = match after.find('{') {
Some(b) => b + 1,
None => return "",
};
let content = &after[brace_start..];
let mut depth = 1usize;
let mut end = 0;
for (i, ch) in content.char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = i;
break;
}
}
_ => {}
}
}
&content[..end]
}
fn extract_fjui_rules(layer_content: &str) -> Vec<RuleBlock> {
let mut rules = Vec::new();
let mut pos = 0;
let bytes = layer_content.as_bytes();
let len = bytes.len();
while pos < len {
let rest = &layer_content[pos..];
let dot_fjui = match rest.find(".fjui-") {
Some(p) => pos + p,
None => break,
};
let sel_start = dot_fjui + 1; let sel_rest = &layer_content[sel_start..];
let sel_end = sel_rest
.find(|c: char| c.is_whitespace() || c == '{' || c == ':' || c == ',')
.unwrap_or(sel_rest.len());
let selector = sel_rest[..sel_end].to_string();
let after_sel = &layer_content[dot_fjui..];
let brace_rel = match after_sel.find('{') {
Some(b) => b,
None => break,
};
let body_start = dot_fjui + brace_rel + 1;
let body_content = &layer_content[body_start..];
let mut depth = 1usize;
let mut body_end = 0;
for (i, ch) in body_content.char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
body_end = i;
break;
}
}
_ => {}
}
}
let body = body_content[..body_end].to_string();
rules.push(RuleBlock { selector, body });
pos = body_start + body_end + 1;
}
rules
}
const NAMED_COLORS: &[&str] = &[
"black",
"white",
"red",
"green",
"blue",
"yellow",
"orange",
"purple",
"gray",
"grey",
"transparent",
];
fn contains_raw_color_literal(value: &str) -> bool {
let v = value.trim();
if v.starts_with("var(--")
|| v == "transparent"
|| v == "currentColor"
|| v == "inherit"
|| v == "initial"
|| v == "none"
|| v == "unset"
|| v.is_empty()
{
return false;
}
if let Some(inner) = v.strip_prefix("color-mix(") {
if inner.contains('#')
|| inner.contains("rgb(")
|| inner.contains("rgba(")
|| inner.contains("hsl(")
|| (inner.contains("oklch(") && !inner.contains("var(--"))
{
return true;
}
let lower = inner.to_lowercase();
for kw in NAMED_COLORS {
if kw == &"transparent" {
continue;
}
if contains_named_color_word(&lower, kw) {
return true;
}
}
return false;
}
if v.contains('#') {
return true;
}
if v.contains("rgb(") || v.contains("rgba(") {
return true;
}
if v.contains("hsl(") {
return true;
}
if v.contains("oklch(") {
return true;
}
let lower = v.to_lowercase();
for kw in NAMED_COLORS {
if kw == &"transparent" {
continue;
}
if contains_named_color_word(&lower, kw) {
return true;
}
}
false
}
fn contains_named_color_word(haystack: &str, keyword: &str) -> bool {
let mut start = 0;
while let Some(pos) = haystack[start..].find(keyword) {
let abs = start + pos;
let before_ok = abs == 0 || {
let c = haystack.as_bytes()[abs - 1] as char;
!c.is_alphanumeric() && c != '-'
};
let after_pos = abs + keyword.len();
let after_ok = after_pos >= haystack.len() || {
let c = haystack.as_bytes()[after_pos] as char;
!c.is_alphanumeric() && c != '-'
};
if before_ok && after_ok {
return true;
}
start = abs + 1;
if start >= haystack.len() {
break;
}
}
false
}
fn check_rule_for_raw_literals(selector: &str, body: &str) -> Vec<Finding> {
let mut findings = Vec::new();
for segment in body.split(';') {
let segment = segment.trim();
if segment.is_empty() || segment.starts_with('}') || segment.starts_with('{') {
continue;
}
if segment.contains('{') {
continue;
}
if let Some(colon_pos) = segment.find(':') {
let prop = segment[..colon_pos].trim().trim_start_matches('&').trim();
let prop = prop.trim_start_matches(':').trim();
let value = segment[colon_pos + 1..].trim();
let is_color_prop = COLOR_PROPERTIES.contains(&prop);
if is_color_prop && contains_raw_color_literal(value) {
findings.push(Finding {
rule: "skin-raw-literals",
element_id: None,
severity: Severity::Warning,
message: format!(
"Rule `.{selector}` contains raw literal `{value}` in `{prop}` — use var(--token) instead."
),
suggestion: "Replace the raw color/size literal with a var(--token-name) reference.".into(),
});
}
}
}
findings
}
const REQUIRED_STATES: &[&str] = &[":hover", ":focus-visible", ":active", ":disabled"];
fn is_interactive(selector: &str) -> bool {
INTERACTIVE_PREFIXES
.iter()
.any(|&p| selector.starts_with(p))
}
fn is_interactive_variant(selector: &str) -> bool {
INTERACTIVE_PREFIXES.iter().any(|&p| {
selector.starts_with(p) && selector.len() > p.len() && selector[p.len()..].starts_with("--")
})
}
pub fn check_skin_raw_literals(css: &str) -> Vec<Finding> {
let layer = extract_components_layer(css);
if layer.is_empty() {
return check_raw_literals_in_content(css);
}
check_raw_literals_in_content(layer)
}
fn check_raw_literals_in_content(content: &str) -> Vec<Finding> {
let rules = extract_fjui_rules(content);
let mut findings = Vec::new();
for rule in &rules {
findings.extend(check_rule_for_raw_literals(&rule.selector, &rule.body));
}
findings
}
pub fn check_skin_interaction_states(css: &str) -> Vec<Finding> {
let layer = extract_components_layer(css);
let search_content = if layer.is_empty() { css } else { layer };
let rules = extract_fjui_rules(search_content);
let mut findings = Vec::new();
for rule in &rules {
let sel = &rule.selector;
if NON_INTERACTIVE_PREFIXES.iter().any(|&p| sel.starts_with(p)) {
continue;
}
if !is_interactive(sel) || is_interactive_variant(sel) {
continue;
}
let required: Vec<&str> = REQUIRED_STATES
.iter()
.filter(|&&s| !(sel == "fjui-table__row" && s == ":disabled"))
.copied()
.collect();
for state in &required {
let in_body = rule.body.contains(state);
let separate_rule = format!("{sel}{state}");
let in_layer = search_content.contains(&separate_rule);
if !in_body && !in_layer {
findings.push(Finding {
rule: "skin-interaction-states",
element_id: None,
severity: Severity::Warning,
message: format!("Rule `.{sel}` is missing interaction state `{state}`."),
suggestion: format!("Add `&{state} {{ ... }}` inside the `.{sel}` rule body."),
});
}
}
}
findings
}
pub fn check_skin_border_and_shadow(css: &str) -> Vec<Finding> {
let layer = extract_components_layer(css);
let content = if layer.is_empty() { css } else { layer };
let rules = extract_fjui_rules(content);
let mut findings = Vec::new();
for rule in &rules {
if has_border_and_shadow_violation(&rule.selector, &rule.body) {
findings.push(Finding {
rule: "skin-border-or-shadow",
element_id: None,
severity: Severity::Warning,
message: format!(
"Rule `.{}` declares both a visible border and a non-none box-shadow — use border OR shadow, never both (LANG-04).",
rule.selector
),
suggestion: "Flat surfaces use border only (no box-shadow). Overlays use box-shadow only (border: none). Move box-shadow inside :focus-visible if it is a focus ring.".into(),
});
}
}
findings
}
fn has_border_and_shadow_violation(selector: &str, body: &str) -> bool {
let top_level = strip_focus_visible_blocks(body);
let has_visible_border = rule_has_visible_border(selector, &top_level);
let has_non_none_shadow = rule_has_non_none_box_shadow(&top_level);
has_visible_border && has_non_none_shadow
}
fn strip_focus_visible_blocks(body: &str) -> String {
let mut result = String::with_capacity(body.len());
let mut pos = 0;
let bytes = body.as_bytes();
let len = body.len();
while pos < len {
let rest = &body[pos..];
if let Some(fv_rel) = rest.find(":focus-visible") {
let fv_abs = pos + fv_rel;
let after_fv = &body[fv_abs + ":focus-visible".len()..];
if let Some(brace_rel) = after_fv.find('{') {
let between = &after_fv[..brace_rel];
if between.chars().all(|c| c.is_whitespace()) {
result.push_str(&body[pos..fv_abs]);
let block_start = fv_abs + ":focus-visible".len() + brace_rel + 1;
let block_body = &body[block_start..];
let mut depth = 1usize;
let mut end_rel = block_body.len();
for (i, ch) in block_body.char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end_rel = i + 1; break;
}
}
_ => {}
}
}
pos = block_start + end_rel;
continue;
}
}
result.push(bytes[pos] as char);
pos += 1;
} else {
result.push_str(&body[pos..]);
break;
}
}
result
}
fn rule_has_visible_border(_selector: &str, body: &str) -> bool {
for segment in body.split(';') {
let segment = segment.trim();
if segment.is_empty() || segment.contains('{') || segment.starts_with('}') {
continue;
}
let Some(colon) = segment.find(':') else {
continue;
};
let prop = segment[..colon].trim().trim_start_matches('&').trim();
let prop = prop.trim_start_matches(':').trim();
let value = segment[colon + 1..].trim();
let is_border_prop = prop == "border"
|| prop == "border-color"
|| prop == "border-left"
|| prop == "border-right"
|| prop == "border-top"
|| prop == "border-bottom"
|| prop == "border-left-color"
|| prop == "border-right-color"
|| prop == "border-top-color"
|| prop == "border-bottom-color";
if !is_border_prop {
continue;
}
let v = value.to_lowercase();
if v == "none" || v == "transparent" || v.is_empty() {
continue;
}
return true;
}
false
}
fn rule_has_non_none_box_shadow(body: &str) -> bool {
for segment in body.split(';') {
let segment = segment.trim();
if segment.is_empty() || segment.contains('{') || segment.starts_with('}') {
continue;
}
let Some(colon) = segment.find(':') else {
continue;
};
let prop = segment[..colon].trim().trim_start_matches('&').trim();
let prop = prop.trim_start_matches(':').trim();
let value = segment[colon + 1..].trim();
if prop != "box-shadow" {
continue;
}
let v = value.to_lowercase();
if v == "none" || v.is_empty() {
continue;
}
return true;
}
false
}
pub fn check_all(css: &str) -> Vec<Finding> {
let mut findings = check_skin_raw_literals(css);
findings.extend(check_skin_interaction_states(css));
findings.extend(check_skin_border_and_shadow(css));
findings
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_hex_in_fjui_rule_returns_warning() {
let css = "@layer components { .fjui-btn { color: #1a1a1a; } }";
let findings = check_skin_raw_literals(css);
assert_eq!(
findings.len(),
1,
"expected 1 warning for raw hex literal, got: {findings:#?}"
);
assert_eq!(findings[0].severity, Severity::Warning);
assert!(
findings[0].message.contains("fjui-btn"),
"message should name the selector"
);
assert!(
findings[0].message.contains("#1a1a1a"),
"message should name the literal"
);
}
#[test]
fn token_var_ref_no_warning() {
let css = "@layer components { .fjui-btn { color: var(--color-text); } }";
let findings = check_skin_raw_literals(css);
assert!(
findings.is_empty(),
"var(--token) must not be flagged, got: {findings:#?}"
);
}
#[test]
fn raw_rgb_in_fjui_rule_returns_warning() {
let css = "@layer components { .fjui-btn { background-color: rgb(255, 0, 0); } }";
let findings = check_skin_raw_literals(css);
assert!(!findings.is_empty(), "rgb() literal must be flagged");
}
#[test]
fn raw_rgba_in_fjui_rule_returns_warning() {
let css = "@layer components { .fjui-btn { background: rgba(0,0,0,0.5); } }";
let findings = check_skin_raw_literals(css);
assert!(!findings.is_empty(), "rgba() literal must be flagged");
}
#[test]
fn raw_oklch_direct_returns_warning() {
let css = "@layer components { .fjui-btn { color: oklch(50% 0 0); } }";
let findings = check_skin_raw_literals(css);
assert!(
!findings.is_empty(),
"oklch() used directly must be flagged"
);
}
#[test]
fn color_mix_with_var_refs_no_warning() {
let css = "@layer components { .fjui-btn { background: color-mix(in oklab, var(--color-primary) 88%, transparent); } }";
let findings = check_skin_raw_literals(css);
assert!(
findings.is_empty(),
"color-mix with var() refs must not be flagged, got: {findings:#?}"
);
}
#[test]
fn complete_fjui_btn_no_warnings() {
let css = "@layer components {
.fjui-btn {
color: var(--color-text);
&:hover { background: var(--color-surface); }
&:focus-visible { outline: 2px solid var(--color-ring); }
&:active { opacity: 0.85; }
&:disabled { opacity: 0.5; }
}
}";
let findings = check_skin_interaction_states(css);
assert!(
findings.is_empty(),
"complete fjui-btn should produce no interaction-state warnings, got: {findings:#?}"
);
}
#[test]
fn fjui_btn_missing_active_returns_warning() {
let css = "@layer components {
.fjui-btn {
color: var(--color-text);
&:hover { background: var(--color-surface); }
&:focus-visible { outline: 2px solid var(--color-ring); }
&:disabled { opacity: 0.5; }
}
}";
let findings = check_skin_interaction_states(css);
assert_eq!(
findings.len(),
1,
"should find exactly 1 missing :active warning, got: {findings:#?}"
);
assert!(
findings[0].message.contains(":active"),
"message must name the missing state"
);
assert!(
findings[0].message.contains("fjui-btn"),
"message must name the selector"
);
}
#[test]
fn non_interactive_fjui_card_no_warnings() {
let css = "@layer components {
.fjui-card {
background: var(--color-card);
border: 1px solid var(--color-border);
}
}";
let findings = check_skin_interaction_states(css);
assert!(
findings.is_empty(),
"fjui-card is non-interactive and must not require interaction states, got: {findings:#?}"
);
}
#[test]
fn non_interactive_fjui_badge_no_warnings() {
let css = "@layer components {
.fjui-badge {
background: var(--color-surface);
}
}";
let findings = check_skin_interaction_states(css);
assert!(
findings.is_empty(),
"fjui-badge must not require interaction states, got: {findings:#?}"
);
}
#[test]
fn fjui_table_row_exempt_from_disabled() {
let css = "@layer components {
.fjui-table__row {
border-bottom: 1px solid var(--color-border);
&:hover { background: var(--color-surface); }
&:focus-visible { outline: 2px solid var(--color-ring); }
&:active { background: var(--color-surface); }
}
}";
let findings = check_skin_interaction_states(css);
assert!(
findings.is_empty(),
"fjui-table__row without :disabled must not warn (rows exempt from :disabled), got: {findings:#?}"
);
}
#[test]
fn ferro_skin_css_contains_tabular_nums_in_numeric_cell() {
let css = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/ferro-skin.css"
))
.expect("assets/ferro-skin.css must exist and be readable");
assert!(
css.contains("fjui-table__cell--numeric"),
"ferro-skin.css must contain the .fjui-table__cell--numeric rule (LANG-03)"
);
let marker = "fjui-table__cell--numeric";
let start = css
.find(marker)
.expect("fjui-table__cell--numeric selector not found");
let brace = css[start..]
.find('{')
.expect("opening brace for fjui-table__cell--numeric not found");
let body_start = start + brace;
let mut depth = 0usize;
let mut body_end = body_start;
for (i, ch) in css[body_start..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
body_end = body_start + i;
break;
}
}
_ => {}
}
}
let numeric_cell_block = &css[body_start..=body_end];
assert!(
numeric_cell_block.contains("tabular-nums"),
"fjui-table__cell--numeric rule must declare font-variant-numeric:tabular-nums (LANG-03); block: {numeric_cell_block}"
);
}
#[test]
fn ferro_skin_css_contains_tabular_nums_in_stat_value() {
let css = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/ferro-skin.css"
))
.expect("assets/ferro-skin.css must exist and be readable");
assert!(
css.contains("fjui-stat-card__value"),
"ferro-skin.css must contain the .fjui-stat-card__value rule (LANG-03)"
);
let marker = "fjui-stat-card__value";
let start = css
.find(marker)
.expect("fjui-stat-card__value selector not found");
let brace = css[start..]
.find('{')
.expect("opening brace for fjui-stat-card__value not found");
let body_start = start + brace;
let mut depth = 0usize;
let mut body_end = body_start;
for (i, ch) in css[body_start..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
body_end = body_start + i;
break;
}
}
_ => {}
}
}
let stat_block = &css[body_start..=body_end];
assert!(
stat_block.contains("tabular-nums"),
"fjui-stat-card__value rule must declare font-variant-numeric:tabular-nums (LANG-03); block: {stat_block}"
);
}
#[test]
fn check_all_combines_both_checks() {
let css = "@layer components {
.fjui-btn {
color: #ff0000;
&:hover { background: var(--color-surface); }
&:focus-visible { outline: 2px solid var(--color-ring); }
&:active { opacity: 0.85; }
&:disabled { opacity: 0.5; }
}
}";
let findings = check_all(css);
assert!(
findings.iter().any(|f| f.rule == "skin-raw-literals"),
"check_all must include raw-literal findings"
);
}
#[test]
fn border_and_shadow_on_same_rule_returns_warning() {
let css = "@layer components {
.fjui-card {
border: 1px solid var(--color-border);
box-shadow: var(--shadow-md);
}
}";
let findings = check_skin_border_and_shadow(css);
assert_eq!(
findings.len(),
1,
"expected 1 border-or-shadow violation, got: {findings:#?}"
);
assert_eq!(findings[0].severity, Severity::Warning);
assert!(
findings[0].message.contains("fjui-card"),
"message must name the selector"
);
assert_eq!(findings[0].rule, "skin-border-or-shadow");
}
#[test]
fn border_only_no_shadow_no_warning() {
let css = "@layer components {
.fjui-card {
border: 1px solid var(--color-border);
}
}";
let findings = check_skin_border_and_shadow(css);
assert!(
findings.is_empty(),
"border only (no shadow) must not be flagged, got: {findings:#?}"
);
}
#[test]
fn shadow_only_no_border_no_warning() {
let css = "@layer components {
.fjui-menu {
box-shadow: var(--shadow-md);
}
}";
let findings = check_skin_border_and_shadow(css);
assert!(
findings.is_empty(),
"shadow only (no border) must not be flagged, got: {findings:#?}"
);
}
#[test]
fn border_none_with_shadow_no_warning() {
let css = "@layer components {
.fjui-btn--primary {
border: none;
box-shadow: var(--shadow-sm);
}
}";
let findings = check_skin_border_and_shadow(css);
assert!(
findings.is_empty(),
"border:none with shadow must not be flagged (border is opted out), got: {findings:#?}"
);
}
#[test]
fn shadow_only_in_focus_visible_no_warning() {
let css = "@layer components {
.fjui-input {
border: 1px solid var(--color-border);
&:focus-visible {
box-shadow: 0 0 0 2px var(--color-ring);
}
}
}";
let findings = check_skin_border_and_shadow(css);
assert!(
findings.is_empty(),
"box-shadow inside :focus-visible with a base border must not be flagged (focus-ring exemption), got: {findings:#?}"
);
}
#[test]
fn hover_shadow_lift_with_base_border_no_warning() {
let css = "@layer components {
.fjui-card {
border: 1px solid var(--color-border);
&:hover {
box-shadow: var(--shadow-md);
}
}
}";
let findings = check_skin_border_and_shadow(css);
assert!(
findings.is_empty(),
"hover-lift shadow (only in :hover) + base border must NOT be flagged — resting state is border-only, got: {findings:#?}"
);
}
#[test]
fn top_level_shadow_with_border_returns_warning() {
let css = "@layer components {
.fjui-card {
border: 1px solid var(--color-border);
box-shadow: var(--shadow-md);
}
}";
let findings = check_skin_border_and_shadow(css);
assert_eq!(
findings.len(),
1,
"top-level border AND top-level box-shadow must be flagged (LANG-04), got: {findings:#?}"
);
assert_eq!(findings[0].rule, "skin-border-or-shadow");
}
#[test]
fn ferro_skin_css_passes_border_or_shadow_rule() {
let css = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/ferro-skin.css"
))
.expect("assets/ferro-skin.css must exist and be readable");
let findings = check_skin_border_and_shadow(&css);
assert!(
findings.is_empty(),
"ferro-skin.css must have zero border-or-shadow violations (LANG-04); found: {findings:#?}"
);
}
#[test]
fn all_tokens_slots_covered_in_tokens_md() {
use ferro_theme::token::ALL_TOKENS;
let tokens_md =
std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/tokens.md"))
.expect("docs/tokens.md must exist and be readable (DX-02)");
let mut missing: Vec<&str> = Vec::new();
for slot in ALL_TOKENS {
if !tokens_md.contains(slot) {
missing.push(slot);
}
}
assert!(
missing.is_empty(),
"docs/tokens.md is missing {} token slot(s) — add them to prevent DX-02 drift: {missing:#?}",
missing.len()
);
}
}