diff --git a/src/browser/tests/css/stylesheet.html b/src/browser/tests/css/stylesheet.html index 59f04d47..e717e806 100644 --- a/src/browser/tests/css/stylesheet.html +++ b/src/browser/tests/css/stylesheet.html @@ -275,3 +275,109 @@ testing.expectEqual('red', div.style.getPropertyValue('color')); } + + + + + + + + diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index a8eb92e4..adc8982a 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -26,6 +26,8 @@ const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); +const Allocator = std.mem.Allocator; + const CSSStyleDeclaration = @This(); _element: ?*Element = null, @@ -114,9 +116,12 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value: const normalized = normalizePropertyName(property_name, &page.buf); + // Normalize the value for canonical serialization + const normalized_value = try normalizePropertyValue(page.call_arena, normalized, value); + // Find existing property if (self.findProperty(normalized)) |existing| { - existing._value = try String.init(page.arena, value, .{}); + existing._value = try String.init(page.arena, normalized_value, .{}); existing._important = important; return; } @@ -125,7 +130,7 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value: const prop = try page._factory.create(Property{ ._node = .{}, ._name = try String.init(page.arena, normalized, .{}), - ._value = try String.init(page.arena, value, .{}), + ._value = try String.init(page.arena, normalized_value, .{}), ._important = important, }); self._properties.append(&prop._node); @@ -227,6 +232,351 @@ fn normalizePropertyName(name: []const u8, buf: []u8) []const u8 { return std.ascii.lowerString(buf, name); } +// Normalize CSS property values for canonical serialization +fn normalizePropertyValue(arena: Allocator, property_name: []const u8, value: []const u8) ![]const u8 { + // Per CSSOM spec, unitless zero in length properties should serialize as "0px" + if (std.mem.eql(u8, value, "0") and isLengthProperty(property_name)) { + return "0px"; + } + + // "first baseline" serializes canonically as "baseline" (first is the default) + if (std.ascii.startsWithIgnoreCase(value, "first baseline")) { + if (value.len == 14) { + // Exact match "first baseline" + return "baseline"; + } + if (value.len > 14 and value[14] == ' ') { + // "first baseline X" -> "baseline X" + return try std.mem.concat(arena, u8, &.{ "baseline", value[14..] }); + } + } + + // For 2-value shorthand properties, collapse "X X" to "X" + if (isTwoValueShorthand(property_name)) { + if (collapseDuplicateValue(value)) |single| { + return single; + } + } + + // Canonicalize anchor-size() function: anchor name (dashed ident) comes before size keyword + if (std.mem.indexOf(u8, value, "anchor-size(") != null) { + return try canonicalizeAnchorSize(arena, value); + } + + return value; +} + +// Canonicalize anchor-size() so that the dashed ident (anchor name) comes before the size keyword. +// e.g. "anchor-size(width --foo)" -> "anchor-size(--foo width)" +fn canonicalizeAnchorSize(arena: Allocator, value: []const u8) ![]const u8 { + var buf = std.Io.Writer.Allocating.init(arena); + var i: usize = 0; + + while (i < value.len) { + // Look for "anchor-size(" + if (std.mem.startsWith(u8, value[i..], "anchor-size(")) { + try buf.writer.writeAll("anchor-size("); + i += "anchor-size(".len; + + // Parse and canonicalize the arguments + i = try canonicalizeAnchorSizeArgs(value, i, &buf.writer); + } else { + try buf.writer.writeByte(value[i]); + i += 1; + } + } + + return buf.written(); +} + +// Parse anchor-size arguments and write them in canonical order +fn canonicalizeAnchorSizeArgs(value: []const u8, start: usize, writer: *std.Io.Writer) !usize { + var i = start; + var depth: usize = 1; + + // Skip leading whitespace + while (i < value.len and value[i] == ' ') : (i += 1) {} + + // Collect tokens before the comma or close paren + var first_token_start: ?usize = null; + var first_token_end: usize = 0; + var second_token_start: ?usize = null; + var second_token_end: usize = 0; + var comma_pos: ?usize = null; + var token_count: usize = 0; + + const args_start = i; + var in_token = false; + + // First pass: find the structure of arguments before comma/closing paren at depth 1 + while (i < value.len and depth > 0) { + const c = value[i]; + + if (c == '(') { + depth += 1; + in_token = true; + i += 1; + } else if (c == ')') { + depth -= 1; + if (depth == 0) { + if (in_token) { + if (token_count == 0) { + first_token_end = i; + } else if (token_count == 1) { + second_token_end = i; + } + } + break; + } + i += 1; + } else if (c == ',' and depth == 1) { + if (in_token) { + if (token_count == 0) { + first_token_end = i; + } else if (token_count == 1) { + second_token_end = i; + } + } + comma_pos = i; + break; + } else if (c == ' ') { + if (in_token and depth == 1) { + if (token_count == 0) { + first_token_end = i; + token_count = 1; + } else if (token_count == 1 and second_token_start != null) { + second_token_end = i; + token_count = 2; + } + in_token = false; + } + i += 1; + } else { + if (!in_token and depth == 1) { + if (token_count == 0) { + first_token_start = i; + } else if (token_count == 1) { + second_token_start = i; + } + in_token = true; + } + i += 1; + } + } + + // Handle end of tokens + if (in_token and token_count == 1 and second_token_start != null) { + second_token_end = i; + token_count = 2; + } else if (in_token and token_count == 0) { + first_token_end = i; + token_count = 1; + } + + // Check if we have exactly two tokens that need reordering + if (token_count == 2) { + const first_start = first_token_start orelse args_start; + const second_start = second_token_start orelse first_token_end; + + const first_token = value[first_start..first_token_end]; + const second_token = value[second_start..second_token_end]; + + // If second token is a dashed ident and first is a size keyword, swap them + if (std.mem.startsWith(u8, second_token, "--") and isAnchorSizeKeyword(first_token)) { + try writer.writeAll(second_token); + try writer.writeByte(' '); + try writer.writeAll(first_token); + } else { + // Keep original order + try writer.writeAll(first_token); + try writer.writeByte(' '); + try writer.writeAll(second_token); + } + } else if (first_token_start) |fts| { + // Single token, just copy it + try writer.writeAll(value[fts..first_token_end]); + } + + // Handle comma and fallback value (may contain nested anchor-size) + if (comma_pos) |cp| { + try writer.writeAll(", "); + i = cp + 1; + // Skip whitespace after comma + while (i < value.len and value[i] == ' ') : (i += 1) {} + + // Copy the fallback, recursively handling nested anchor-size + while (i < value.len and depth > 0) { + if (std.mem.startsWith(u8, value[i..], "anchor-size(")) { + try writer.writeAll("anchor-size("); + i += "anchor-size(".len; + depth += 1; + i = try canonicalizeAnchorSizeArgs(value, i, writer); + depth -= 1; + } else if (value[i] == '(') { + depth += 1; + try writer.writeByte(value[i]); + i += 1; + } else if (value[i] == ')') { + depth -= 1; + if (depth == 0) break; + try writer.writeByte(value[i]); + i += 1; + } else { + try writer.writeByte(value[i]); + i += 1; + } + } + } + + // Write closing paren + try writer.writeByte(')'); + + return i + 1; // Skip past the closing paren +} + +fn isAnchorSizeKeyword(token: []const u8) bool { + const keywords = std.StaticStringMap(void).initComptime(.{ + .{ "width", {} }, + .{ "height", {} }, + .{ "block", {} }, + .{ "inline", {} }, + .{ "self-block", {} }, + .{ "self-inline", {} }, + }); + return keywords.has(token); +} + +// Check if a value is "X X" (duplicate) and return just "X" +fn collapseDuplicateValue(value: []const u8) ?[]const u8 { + const space_idx = std.mem.indexOfScalar(u8, value, ' ') orelse return null; + if (space_idx == 0 or space_idx >= value.len - 1) return null; + + const first = value[0..space_idx]; + const rest = std.mem.trimLeft(u8, value[space_idx + 1 ..], " "); + + // Check if there's only one more value (no additional spaces) + if (std.mem.indexOfScalar(u8, rest, ' ') != null) return null; + + if (std.mem.eql(u8, first, rest)) { + return first; + } + return null; +} + +fn isTwoValueShorthand(name: []const u8) bool { + const shorthands = std.StaticStringMap(void).initComptime(.{ + .{ "place-content", {} }, + .{ "place-items", {} }, + .{ "place-self", {} }, + .{ "margin-block", {} }, + .{ "margin-inline", {} }, + .{ "padding-block", {} }, + .{ "padding-inline", {} }, + .{ "inset-block", {} }, + .{ "inset-inline", {} }, + .{ "border-block-style", {} }, + .{ "border-inline-style", {} }, + .{ "border-block-width", {} }, + .{ "border-inline-width", {} }, + .{ "border-block-color", {} }, + .{ "border-inline-color", {} }, + .{ "overflow", {} }, + .{ "overscroll-behavior", {} }, + .{ "gap", {} }, + }); + return shorthands.has(name); +} + +fn isLengthProperty(name: []const u8) bool { + // Properties that accept or values + const length_properties = std.StaticStringMap(void).initComptime(.{ + // Sizing + .{ "width", {} }, + .{ "height", {} }, + .{ "min-width", {} }, + .{ "min-height", {} }, + .{ "max-width", {} }, + .{ "max-height", {} }, + // Margins + .{ "margin", {} }, + .{ "margin-top", {} }, + .{ "margin-right", {} }, + .{ "margin-bottom", {} }, + .{ "margin-left", {} }, + .{ "margin-block", {} }, + .{ "margin-block-start", {} }, + .{ "margin-block-end", {} }, + .{ "margin-inline", {} }, + .{ "margin-inline-start", {} }, + .{ "margin-inline-end", {} }, + // Padding + .{ "padding", {} }, + .{ "padding-top", {} }, + .{ "padding-right", {} }, + .{ "padding-bottom", {} }, + .{ "padding-left", {} }, + .{ "padding-block", {} }, + .{ "padding-block-start", {} }, + .{ "padding-block-end", {} }, + .{ "padding-inline", {} }, + .{ "padding-inline-start", {} }, + .{ "padding-inline-end", {} }, + // Positioning + .{ "top", {} }, + .{ "right", {} }, + .{ "bottom", {} }, + .{ "left", {} }, + .{ "inset", {} }, + .{ "inset-block", {} }, + .{ "inset-block-start", {} }, + .{ "inset-block-end", {} }, + .{ "inset-inline", {} }, + .{ "inset-inline-start", {} }, + .{ "inset-inline-end", {} }, + // Border + .{ "border-width", {} }, + .{ "border-top-width", {} }, + .{ "border-right-width", {} }, + .{ "border-bottom-width", {} }, + .{ "border-left-width", {} }, + .{ "border-block-width", {} }, + .{ "border-block-start-width", {} }, + .{ "border-block-end-width", {} }, + .{ "border-inline-width", {} }, + .{ "border-inline-start-width", {} }, + .{ "border-inline-end-width", {} }, + .{ "border-radius", {} }, + .{ "border-top-left-radius", {} }, + .{ "border-top-right-radius", {} }, + .{ "border-bottom-left-radius", {} }, + .{ "border-bottom-right-radius", {} }, + // Text + .{ "font-size", {} }, + .{ "line-height", {} }, + .{ "letter-spacing", {} }, + .{ "word-spacing", {} }, + .{ "text-indent", {} }, + // Flexbox/Grid + .{ "gap", {} }, + .{ "row-gap", {} }, + .{ "column-gap", {} }, + .{ "flex-basis", {} }, + // Outline + .{ "outline-width", {} }, + .{ "outline-offset", {} }, + // Other + .{ "border-spacing", {} }, + .{ "text-shadow", {} }, + .{ "box-shadow", {} }, + .{ "baseline-shift", {} }, + .{ "vertical-align", {} }, + // Grid lanes + .{ "flow-tolerance", {} }, + }); + + return length_properties.has(name); +} + fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, normalized_name: []const u8) []const u8 { if (std.mem.eql(u8, normalized_name, "visibility")) { return "visible"; diff --git a/src/browser/webapi/css/CSSStyleProperties.zig b/src/browser/webapi/css/CSSStyleProperties.zig index 1bd51a4a..1b6def0e 100644 --- a/src/browser/webapi/css/CSSStyleProperties.zig +++ b/src/browser/webapi/css/CSSStyleProperties.zig @@ -91,39 +91,224 @@ pub fn getNamed(self: *CSSStyleProperties, name: []const u8, page: *Page) ![]con } fn isKnownCSSProperty(dash_case: []const u8) bool { - // List of common/known CSS properties - // In a full implementation, this would include all standard CSS properties const known_properties = std.StaticStringMap(void).initComptime(.{ + // Colors & backgrounds .{ "color", {} }, + .{ "background", {} }, .{ "background-color", {} }, + .{ "background-image", {} }, + .{ "background-position", {} }, + .{ "background-repeat", {} }, + .{ "background-size", {} }, + .{ "background-attachment", {} }, + .{ "background-clip", {} }, + .{ "background-origin", {} }, + // Typography + .{ "font", {} }, + .{ "font-family", {} }, .{ "font-size", {} }, + .{ "font-style", {} }, + .{ "font-weight", {} }, + .{ "font-variant", {} }, + .{ "line-height", {} }, + .{ "letter-spacing", {} }, + .{ "word-spacing", {} }, + .{ "text-align", {} }, + .{ "text-decoration", {} }, + .{ "text-indent", {} }, + .{ "text-transform", {} }, + .{ "white-space", {} }, + .{ "word-break", {} }, + .{ "word-wrap", {} }, + .{ "overflow-wrap", {} }, + // Box model + .{ "margin", {} }, .{ "margin-top", {} }, + .{ "margin-right", {} }, .{ "margin-bottom", {} }, .{ "margin-left", {} }, - .{ "margin-right", {} }, + .{ "margin-block", {} }, + .{ "margin-block-start", {} }, + .{ "margin-block-end", {} }, + .{ "margin-inline", {} }, + .{ "margin-inline-start", {} }, + .{ "margin-inline-end", {} }, + .{ "padding", {} }, .{ "padding-top", {} }, + .{ "padding-right", {} }, .{ "padding-bottom", {} }, .{ "padding-left", {} }, - .{ "padding-right", {} }, + .{ "padding-block", {} }, + .{ "padding-block-start", {} }, + .{ "padding-block-end", {} }, + .{ "padding-inline", {} }, + .{ "padding-inline-start", {} }, + .{ "padding-inline-end", {} }, + // Border + .{ "border", {} }, + .{ "border-width", {} }, + .{ "border-style", {} }, + .{ "border-color", {} }, + .{ "border-top", {} }, + .{ "border-top-width", {} }, + .{ "border-top-style", {} }, + .{ "border-top-color", {} }, + .{ "border-right", {} }, + .{ "border-right-width", {} }, + .{ "border-right-style", {} }, + .{ "border-right-color", {} }, + .{ "border-bottom", {} }, + .{ "border-bottom-width", {} }, + .{ "border-bottom-style", {} }, + .{ "border-bottom-color", {} }, + .{ "border-left", {} }, + .{ "border-left-width", {} }, + .{ "border-left-style", {} }, + .{ "border-left-color", {} }, + .{ "border-radius", {} }, .{ "border-top-left-radius", {} }, .{ "border-top-right-radius", {} }, .{ "border-bottom-left-radius", {} }, .{ "border-bottom-right-radius", {} }, - .{ "float", {} }, - .{ "z-index", {} }, + .{ "border-collapse", {} }, + .{ "border-spacing", {} }, + // Sizing .{ "width", {} }, .{ "height", {} }, + .{ "min-width", {} }, + .{ "min-height", {} }, + .{ "max-width", {} }, + .{ "max-height", {} }, + .{ "box-sizing", {} }, + // Positioning + .{ "position", {} }, + .{ "top", {} }, + .{ "right", {} }, + .{ "bottom", {} }, + .{ "left", {} }, + .{ "inset", {} }, + .{ "inset-block", {} }, + .{ "inset-block-start", {} }, + .{ "inset-block-end", {} }, + .{ "inset-inline", {} }, + .{ "inset-inline-start", {} }, + .{ "inset-inline-end", {} }, + .{ "z-index", {} }, + .{ "float", {} }, + .{ "clear", {} }, + // Display & visibility .{ "display", {} }, .{ "visibility", {} }, .{ "opacity", {} }, - .{ "filter", {} }, + .{ "overflow", {} }, + .{ "overflow-x", {} }, + .{ "overflow-y", {} }, + .{ "clip", {} }, + .{ "clip-path", {} }, + // Flexbox + .{ "flex", {} }, + .{ "flex-direction", {} }, + .{ "flex-wrap", {} }, + .{ "flex-flow", {} }, + .{ "flex-grow", {} }, + .{ "flex-shrink", {} }, + .{ "flex-basis", {} }, + .{ "order", {} }, + // Grid + .{ "grid", {} }, + .{ "grid-template", {} }, + .{ "grid-template-columns", {} }, + .{ "grid-template-rows", {} }, + .{ "grid-template-areas", {} }, + .{ "grid-auto-columns", {} }, + .{ "grid-auto-rows", {} }, + .{ "grid-auto-flow", {} }, + .{ "grid-column", {} }, + .{ "grid-column-start", {} }, + .{ "grid-column-end", {} }, + .{ "grid-row", {} }, + .{ "grid-row-start", {} }, + .{ "grid-row-end", {} }, + .{ "grid-area", {} }, + .{ "gap", {} }, + .{ "row-gap", {} }, + .{ "column-gap", {} }, + // Alignment (flexbox & grid) + .{ "align-content", {} }, + .{ "align-items", {} }, + .{ "align-self", {} }, + .{ "justify-content", {} }, + .{ "justify-items", {} }, + .{ "justify-self", {} }, + .{ "place-content", {} }, + .{ "place-items", {} }, + .{ "place-self", {} }, + // Transforms & animations .{ "transform", {} }, + .{ "transform-origin", {} }, + .{ "transform-style", {} }, + .{ "perspective", {} }, + .{ "perspective-origin", {} }, .{ "transition", {} }, - .{ "position", {} }, - .{ "top", {} }, - .{ "bottom", {} }, - .{ "left", {} }, - .{ "right", {} }, + .{ "transition-property", {} }, + .{ "transition-duration", {} }, + .{ "transition-timing-function", {} }, + .{ "transition-delay", {} }, + .{ "animation", {} }, + .{ "animation-name", {} }, + .{ "animation-duration", {} }, + .{ "animation-timing-function", {} }, + .{ "animation-delay", {} }, + .{ "animation-iteration-count", {} }, + .{ "animation-direction", {} }, + .{ "animation-fill-mode", {} }, + .{ "animation-play-state", {} }, + // Filters & effects + .{ "filter", {} }, + .{ "backdrop-filter", {} }, + .{ "box-shadow", {} }, + .{ "text-shadow", {} }, + // Outline + .{ "outline", {} }, + .{ "outline-width", {} }, + .{ "outline-style", {} }, + .{ "outline-color", {} }, + .{ "outline-offset", {} }, + // Lists + .{ "list-style", {} }, + .{ "list-style-type", {} }, + .{ "list-style-position", {} }, + .{ "list-style-image", {} }, + // Tables + .{ "table-layout", {} }, + .{ "caption-side", {} }, + .{ "empty-cells", {} }, + // Misc + .{ "cursor", {} }, + .{ "pointer-events", {} }, + .{ "user-select", {} }, + .{ "resize", {} }, + .{ "object-fit", {} }, + .{ "object-position", {} }, + .{ "vertical-align", {} }, + .{ "content", {} }, + .{ "quotes", {} }, + .{ "counter-reset", {} }, + .{ "counter-increment", {} }, + // Scrolling + .{ "scroll-behavior", {} }, + .{ "scroll-margin", {} }, + .{ "scroll-padding", {} }, + .{ "overscroll-behavior", {} }, + .{ "overscroll-behavior-x", {} }, + .{ "overscroll-behavior-y", {} }, + // Containment + .{ "contain", {} }, + .{ "container", {} }, + .{ "container-type", {} }, + .{ "container-name", {} }, + // Aspect ratio + .{ "aspect-ratio", {} }, }); return known_properties.has(dash_case);