on master help sign in
Lists and records instead of strings

JSON arrays are lists: @each binds each item, and a record's fields are $item.key, so values with spaces or commas survive (they were split on both). The second @each variable is always the index; $_index and the space-splitting destructuring are gone. @data takes inline JSON, a glob is a list of records ($item.file is the file name), length() counts a list's items and contains tests membership, replacing $x._count. A missing list variable is an empty list rather than its own name. htmlang upgrade converts inline pairs to records, $_index to a named index and ._count to length(), and flags ambiguous loops.

commitf15f89b478233669d2f88419a633582a6f072934 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 03:11 (2 weeks ago) parent75da6a7
14 files changed+459−219
DESIGN.md+27−12 crates/htmlang-core/src/expr.rs+41−14 crates/htmlang-core/src/parser.rs+173−154 editors/vscode/snippets/htmlang.json+4−3 examples/demo.hl+8−3 src/bin/htmlang_lsp/docs.rs+3−3 src/upgrade.rs+169−1 tests/regressions.rs+4−4 tests/snapshots.rs+19−16 tests/snapshots/data_directive.hl+3−2 tests/snapshots/data_directive.html+1−1 tests/snapshots/each_destructuring.hl+4−3 tests/snapshots/repeat_directive.hl+2−2 tests/snapshots/stress_large.html+1−1
DESIGN.md+27−12
@@ -340,10 +340,10 @@ Conditions and computed values (`@let x = ...`) are expressions:
340340|---|---|
341341| Values | numbers, `"strings"` (with `$var` interpolation), `$variables`, `true`, `false`, and bare words (`dark`, `#fff`) as strings |
342342| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
343−| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains`, `starts-with`, `ends-with` |
343+| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains` (in text, or as an item of a list), `starts-with`, `ends-with` |
344344| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
345345| Choice | `if(CONDITION, A, B)` |
346−| Text functions | `uppercase(s)`, `lowercase(s)`, `capitalize(s)`, `trim(s)`, `length(s)`, `reverse(s)`, `truncate(s, n)`, `replace(s, old, new)`, `default(s, fallback)` |
346+| Text functions | `uppercase(s)`, `lowercase(s)`, `capitalize(s)`, `trim(s)`, `length(s)` (a list's items, or a text's characters), `reverse(s)`, `truncate(s, n)`, `replace(s, old, new)`, `default(s, fallback)` |
347347| Color functions | `lighten(c, pct)`, `darken(c, pct)`, `alpha(c, a)`, `mix(c1, c2, pct)` |
348348
349349Variables are looked up while evaluating, so a value containing `==` or spaces
@@ -373,27 +373,39 @@ All control flow runs at compile time.
373373@else
374374 @text Few
375375
376−@each $item in $items
377− @text $_index: $item
376+@each $item, $i in $items
377+ @text $i: $item
378378@else
379379 @text The list is empty.
380380
381−@each $i in 1..10 step 3
382− @text $i
383−
384−@each $label, $url in Home /, About /about
385− @link $url $label
381+@each $n in 1..10 step 3
382+ @text $n
386383```
387384
388−A range counts down when its start is greater than its end.
385+`@each $item in LIST` loops over a list: a list loaded with `@data`, text
386+split on commas, or a range (which counts down when its start is greater than
387+its end). An optional second variable is the index, from 0. An item loaded from
388+JSON can be a record, whose fields are `$item.key`:
389+
390+```
391+@data $links [
392+ {"label": "About us", "url": "/about"},
393+ {"label": "Blog", "url": "/blog"}
394+]
395+@row [spacing 12]
396+ @each $link in $links
397+ @link $link.url $link.label
398+@text ${length($links)} links
399+```
389400
390401## Files and data
391402
392403| Directive | Effect |
393404|---|---|
394405| `@include file.hl` | Insert another file: its content and definitions (a library of `@let`s emits nothing) |
395−| `@data $name file.json` | Load JSON values as variables (`$name.key`) |
396−| `@data $name dir/*.json` | Load each file as `$name.STEM.key`; `$name` lists the stems |
406+| `@data $name file.json` | Load JSON values as variables (`$name.key`; arrays are lists) |
407+| `@data $name [...]` / `@data $name {...}` | Inline JSON (an array may span lines) |
408+| `@data $name dir/*.json` | A list with one record per file, in name order; `$item.file` is the file's name |
397409| `@data $name env:NAME [default]` | Read an environment variable |
398410| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
399411| `@image [inline] file.svg` | Inline an SVG file (`width`, `height`, `color`, `class=`, `id=` apply to it) |
@@ -488,6 +500,9 @@ removed form with its replacement.
488500| `$a ~ " " ~ $b` | `"$a $b"` |
489501| `@each $x in LIST [page N]` | Split the list, or filter it with `@if` |
490502| `...$bundle` | `$bundle` |
503+| `@each $a, $b in A x, B y` (items split on spaces) | Records: `@data $rows [{"a": "A", "b": "x"}, ...]` and `@each $row in $rows` with `$row.a` |
504+| `$_index` | `@each $item, $i in ...` |
505+| `$posts._count`, `$posts._keys`, `$posts.STEM.key` | `length($posts)`; nothing; `@each $post in $posts` with `$post.file` |
491506| `@raw """ ... """` | `@raw` with an indented body |
492507| `@keyframes NAME` with `from [opacity 0]` | The `@keyframes` rule in `@style` |
493508| `@include lib.hl as ui` | `@include lib.hl`, without the `ui.` prefix |
crates/htmlang-core/src/expr.rs+41−14
@@ -19,7 +19,9 @@
1919//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
2020//! ```
2121//!
22−//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is a string. Functions:
22+//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is a string. A variable
23+//! loaded from a JSON array is a list: `length` counts its items and
24+//! `contains` tests membership. Functions:
2325//! `if(cond, a, b)`, text (`uppercase`, `lowercase`, `capitalize`, `trim`,
2426//! `length`, `reverse`, `truncate(s, n)`, `replace(s, old, new)`,
2527//! `default(s, fallback)`) and colors (`lighten(c, pct)`, `darken(c, pct)`,
@@ -33,6 +35,8 @@ pub enum Value {
3335 Num(f64),
3436 Str(String),
3537 Bool(bool),
38+ /// A list's items as text (from a JSON array).
39+ List(Vec<String>),
3640}
3741
3842impl Value {
@@ -42,6 +46,7 @@ impl Value {
4246 Value::Bool(b) => *b,
4347 Value::Num(n) => *n != 0.0,
4448 Value::Str(s) => !s.is_empty() && s != "false" && s != "0",
49+ Value::List(items) => !items.is_empty(),
4550 }
4651 }
4752
@@ -49,7 +54,7 @@ impl Value {
4954 match self {
5055 Value::Num(n) => Some(*n),
5156 Value::Str(s) => s.trim().parse().ok(),
52− Value::Bool(_) => None,
57+ Value::Bool(_) | Value::List(_) => None,
5358 }
5459 }
5560}
@@ -61,13 +66,14 @@ impl fmt::Display for Value {
6166 Value::Num(n) => write!(f, "{}", n),
6267 Value::Str(s) => f.write_str(s),
6368 Value::Bool(b) => write!(f, "{}", b),
69+ Value::List(items) => f.write_str(&items.join(", ")),
6470 }
6571 }
6672}
6773
6874/// Resolves a variable name (the text after `$`). Returns `None` for
6975/// undefined variables.
70−pub type Resolver<'a> = &'a dyn Fn(&str) -> Option<String>;
76+pub type Resolver<'a> = &'a dyn Fn(&str) -> Option<Value>;
7177
7278/// Evaluate `src`. Undefined variables are empty strings.
7379pub fn eval(src: &str, resolve: Resolver) -> Result<Value, String> {
@@ -276,7 +282,10 @@ impl Parser<'_> {
276282 (">", None) => l > r,
277283 ("<=", None) => l <= r,
278284 (">=", None) => l >= r,
279− ("contains", _) => l.contains(&r),
285+ ("contains", _) => match &left {
286+ Value::List(items) => items.contains(&r),
287+ _ => l.contains(&r),
288+ },
280289 ("starts-with", _) => l.starts_with(&r),
281290 ("ends-with", _) => l.ends_with(&r),
282291 _ => unreachable!(),
@@ -321,7 +330,7 @@ impl Parser<'_> {
321330 match token {
322331 Token::Num(n) => Ok(Value::Num(n)),
323332 Token::Str(s) => Ok(Value::Str(interpolate(&s, self.resolve))),
324− Token::Var(name) => Ok(Value::Str((self.resolve)(&name).unwrap_or_default())),
333+ Token::Var(name) => Ok((self.resolve)(&name).unwrap_or(Value::Str(String::new()))),
325334 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
326335 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
327336 Token::Word(name) if self.peek() == Some(&Token::LParen) => {
@@ -404,7 +413,13 @@ fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
404413 })
405414 }
406415 "trim" => { arity(1)?; Value::Str(text(0).trim().to_string()) }
407− "length" => { arity(1)?; Value::Num(text(0).chars().count() as f64) }
416+ "length" => {
417+ arity(1)?;
418+ Value::Num(match &args[0] {
419+ Value::List(items) => items.len(),
420+ other => other.to_string().chars().count(),
421+ } as f64)
422+ }
408423 "reverse" => { arity(1)?; Value::Str(text(0).chars().rev().collect()) }
409424 "truncate" => {
410425 arity(2)?;
@@ -503,7 +518,7 @@ fn interpolate(s: &str, resolve: Resolver) -> String {
503518 if end == 0 {
504519 out.push('$');
505520 } else {
506− out.push_str(&resolve(&after[..end]).unwrap_or_default());
521+ out.push_str(&resolve(&after[..end]).map(|v| v.to_string()).unwrap_or_default());
507522 }
508523 rest = &after[end..];
509524 }
@@ -516,13 +531,17 @@ mod tests {
516531 use super::*;
517532
518533 fn ev(src: &str) -> Value {
519− let resolve = |name: &str| match name {
520− "count" => Some("3".to_string()),
521− "name" => Some("World".to_string()),
522− "theme" => Some("dark".to_string()),
523− "tricky" => Some("a == b".to_string()),
524− "empty" => Some(String::new()),
525− _ => None,
534+ let resolve = |name: &str| {
535+ let text = |s: &str| Some(Value::Str(s.to_string()));
536+ match name {
537+ "tags" => Some(Value::List(vec!["rust".into(), "web dev".into()])),
538+ "count" => text("3"),
539+ "name" => text("World"),
540+ "theme" => text("dark"),
541+ "tricky" => text("a == b"),
542+ "empty" => text(""),
543+ _ => None,
544+ }
526545 };
527546 eval(src, &resolve).unwrap()
528547 }
@@ -573,6 +592,14 @@ mod tests {
573592 assert_eq!(ev("alpha(#3b82f6, 0.5)").to_string(), "#3b82f67f");
574593 }
575594
595+ #[test]
596+ fn lists() {
597+ assert_eq!(ev("length($tags)").to_string(), "2");
598+ assert!(ev("$tags contains \"web dev\"").truthy());
599+ assert!(!ev("$tags contains web").truthy());
600+ assert_eq!(ev("$tags").to_string(), "rust, web dev");
601+ }
602+
576603 #[test]
577604 fn errors_are_reported() {
578605 let none = |_: &str| None;
crates/htmlang-core/src/parser.rs+173−154
@@ -104,8 +104,7 @@ impl ParseContext {
104104 fn eval(&mut self, src: &str, line: usize) -> Option<crate::expr::Value> {
105105 track_var_refs(src, &mut self.used_variables);
106106 let vars = &self.variables;
107− let resolve = |name: &str| vars.get(name).cloned();
108− let result = crate::expr::eval(src, &resolve);
107+ let result = crate::expr::eval(src, &|name: &str| lookup(vars, name));
109108 match result {
110109 Ok(value) => Some(value),
111110 Err(message) => {
@@ -644,8 +643,8 @@ impl Evaluator {
644643 // --- @data (load JSON file into variables) ---
645644
646645 // @data $name file.json values as $name.key
647− // @data $name dir/*.json each file as $name.STEM.key; $name
648− // lists the stems, $name._count counts them
646+ // @data $name dir/*.json a list of the files' records
647+ // @data $name [...] / {...} inline JSON
649648 // @data $name env:NAME [DEFAULT] an environment variable
650649 if let Some(rest) = content.strip_prefix("@data ") {
651650 let rest = rest.trim();
@@ -687,56 +686,49 @@ impl Evaluator {
687686 return Ok(None);
688687 }
689688
690− let filename = substitute_vars(&filename, &ctx.variables);
691− if filename.contains('*') {
692− self.load_data_glob(&prefix, &filename, line_num, &content, ctx);
693− return Ok(None);
694− }
695−
696− let filename = substitute_vars(&filename, &ctx.variables);
697− let resolved = match &ctx.base_path {
698− Some(base) => base.join(&filename),
699− None => PathBuf::from(&filename),
700− };
701−
702− let json_text = match std::fs::read_to_string(&resolved) {
703− Ok(text) => text,
704− Err(e) => {
705− ctx.diagnostics.push(Diagnostic {
706− line: line_num,
707− column: None,
708− message: format!("cannot load data '{}': {}", filename, e),
709− severity: Severity::Error,
710− source_line: Some(content.clone()),
711− });
689+ // Inline JSON: @data $links [{"label": "Home", "url": "/"}]
690+ let (json_text, source) = if filename.starts_with(['[', '{']) {
691+ (filename, "the inline data".to_string())
692+ } else {
693+ let filename = substitute_vars(&filename, &ctx.variables);
694+ if filename.contains('*') {
695+ self.load_data_glob(&prefix, &filename, line_num, &content, ctx);
712696 return Ok(None);
713697 }
714− };
715−
716− match parse_json_with_error(&json_text) {
717− Ok(json) => {
718− let mut sub = HashMap::new();
719− flatten_json(&prefix, &json, &mut sub);
720− for (k, v) in sub {
721− ctx.variables.insert(k, v);
698+ let resolved = match &ctx.base_path {
699+ Some(base) => base.join(&filename),
700+ None => PathBuf::from(&filename),
701+ };
702+ match std::fs::read_to_string(&resolved) {
703+ Ok(text) => {
704+ ctx.included_files.push(resolved);
705+ (text, format!("'{}'", filename))
706+ }
707+ Err(e) => {
708+ ctx.diagnostics.push(Diagnostic {
709+ line: line_num,
710+ column: None,
711+ message: format!("cannot load data '{}': {}", filename, e),
712+ severity: Severity::Error,
713+ source_line: Some(content.clone()),
714+ });
715+ return Ok(None);
722716 }
723717 }
724− Err(detail) => {
725− ctx.diagnostics.push(Diagnostic {
726− line: line_num,
727− column: None,
728− message: format!("invalid JSON in '{}': {}", filename, detail),
729− severity: Severity::Error,
730− source_line: Some(content.clone()),
731− });
732− }
718+ };
719+ match parse_json_with_error(&json_text) {
720+ Ok(json) => flatten_json(&prefix, &json, &mut ctx.variables),
721+ Err(detail) => ctx.diagnostics.push(Diagnostic {
722+ line: line_num,
723+ column: None,
724+ message: format!("invalid JSON in {}: {}", source, detail),
725+ severity: Severity::Error,
726+ source_line: Some(content.clone()),
727+ }),
733728 }
734−
735− ctx.included_files.push(resolved);
736729 return Ok(None);
737730 }
738731
739−
740732 // --- @if / @else ---
741733
742734 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
@@ -814,8 +806,8 @@ impl Evaluator {
814806 Ok(Some(vec![Node::Text(segments)]))
815807 }
816808
817− /// `@data $name dir/*.json`: load every matching file as
818− /// `$name.STEM.key`, list the stems in `$name` and count them.
809+ /// `@data $name dir/*.json`: a list with one record per matching file
810+ /// (in name order), whose `file` is the file's name without extension.
819811 fn load_data_glob(
820812 &mut self,
821813 name: &str,
@@ -856,7 +848,7 @@ impl Evaluator {
856848 }
857849 };
858850 files.sort();
859− let mut stems = Vec::new();
851+ let mut records = Vec::new();
860852 for file in files {
861853 let stem = file
862854 .file_stem()
@@ -877,12 +869,19 @@ impl Evaluator {
877869 }
878870 };
879871 match parse_json_with_error(&text) {
880− Ok(json) => {
881− let mut vars = HashMap::new();
882− flatten_json(&format!("{}.{}", name, stem), &json, &mut vars);
883− ctx.variables.extend(vars);
884− stems.push(stem);
872+ Ok(JsonValue::Object(mut pairs)) => {
873+ if !pairs.iter().any(|(key, _)| key == "file") {
874+ pairs.push(("file".to_string(), JsonValue::Str(stem)));
875+ }
876+ records.push(JsonValue::Object(pairs));
885877 }
878+ Ok(_) => ctx.diagnostics.push(Diagnostic {
879+ line: line_num,
880+ column: None,
881+ message: format!("'{}' should hold a JSON object", file.display()),
882+ severity: Severity::Error,
883+ source_line: Some(content.to_string()),
884+ }),
886885 Err(detail) => ctx.diagnostics.push(Diagnostic {
887886 line: line_num,
888887 column: None,
@@ -893,11 +892,12 @@ impl Evaluator {
893892 }
894893 ctx.included_files.push(file);
895894 }
896− ctx.variables
897− .insert(format!("{}._count", name), stems.len().to_string());
898− ctx.variables.insert(name.to_string(), stems.join(", "));
895+ flatten_json(name, &JsonValue::Array(records), &mut ctx.variables);
899896 }
900897
898+ /// `@each $item in LIST` or `@each $item, $index in LIST`. A LIST from
899+ /// JSON binds each record or value to `$item`; any other list is text,
900+ /// split on commas, or a range `A..B [step N]`.
901901 fn eval_each(
902902 &mut self,
903903 header: &str,
@@ -906,25 +906,24 @@ impl Evaluator {
906906 line_num: usize,
907907 ctx: &mut ParseContext,
908908 ) -> Result<Vec<Node>, ParseError> {
909− // Support: @each $var in list OR @each $var, $index in list
910− // OR @each $name, $url in Alice /alice, Bob /bob (destructuring)
911− let (var_names, list_str) = if let Some((before_in, after_in)) = header.split_once(" in ") {
912− let before_in = before_in.trim();
913− let vars: Vec<String> = before_in
914− .split(',')
915− .map(|v| v.trim().strip_prefix('$').unwrap_or(v.trim()).to_string())
916− .collect();
917− (vars, after_in.trim().to_string())
918− } else {
909+ let Some((names, list_src)) = header.split_once(" in ") else {
919910 return Err(ParseError {
920911 line: line_num,
921− message: "@each requires: @each $var in list".to_string(),
912+ message: "@each requires: @each $item in LIST".to_string(),
922913 });
923914 };
924− let var_name = var_names[0].clone();
925− let index_var = var_names.get(1).cloned();
926−
927− if list_str.trim_end().ends_with(']') && list_str.contains("[page ") {
915+ let names: Vec<&str> = names.split(',').map(|v| v.trim().trim_start_matches('$')).collect();
916+ if names.len() > 2 {
917+ return Err(ParseError {
918+ line: line_num,
919+ message: "@each takes `$item` or `$item, $index`: to loop over records, \
920+ load them with @data and use `$item.key`"
921+ .to_string(),
922+ });
923+ }
924+ let (item, index) = (names[0], names.get(1).copied());
925+ let list_src = list_src.trim();
926+ if list_src.ends_with(']') && list_src.contains("[page ") {
928927 return Err(ParseError {
929928 line: line_num,
930929 message: "@each pagination (`[page N]`) was removed: split the list or \
@@ -932,68 +931,61 @@ impl Evaluator {
932931 .to_string(),
933932 });
934933 }
935− track_var_refs(&list_str, &mut ctx.used_variables);
936− let list_str = substitute_vars(&list_str, &ctx.variables);
937− // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
938− let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
939− let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
940− (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
941− } else {
942− (rest.trim(), 1i64)
943− };
944− if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
945− {
946− numeric_range(start, end, step)
947− } else {
948− list_str
949− .split(',')
950− .map(|s| s.trim().to_string())
951− .filter(|s| !s.is_empty())
952− .collect()
934+ track_var_refs(list_src, &mut ctx.used_variables);
935+
936+ // A list loaded from JSON, by name
937+ let data_list = list_src.strip_prefix('$').and_then(|name| {
938+ let len = ctx.variables.get(&format!("{}#", name))?.parse::<usize>().ok()?;
939+ Some((name.to_string(), len))
940+ });
941+ let undefined = list_src.strip_prefix('$').filter(|name| {
942+ name.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
943+ && !ctx.variables.contains_key(*name)
944+ });
945+ let text_items: Vec<String> = match (&data_list, undefined) {
946+ (Some(_), _) => Vec::new(),
947+ // A missing variable is an empty list: quietly for a record's
948+ // missing field (`$post.tags`), with a warning otherwise
949+ (None, Some(name)) => {
950+ let root = name.split('.').next().unwrap_or(name);
951+ if !ctx.variables.contains_key(root) {
952+ ctx.diagnostics.push(Diagnostic {
953+ line: line_num,
954+ column: None,
955+ message: format!("undefined variable '${}'", name),
956+ severity: Severity::Warning,
957+ source_line: Some(format!("@each {}", header)),
958+ });
959+ }
960+ Vec::new()
953961 }
954− } else {
955− list_str
956− .split(',')
957− .map(|s| s.trim().to_string())
958− .filter(|s| !s.is_empty())
959− .collect()
962+ (None, None) => text_list_items(&substitute_vars(list_src, &ctx.variables)),
960963 };
964+ let count = data_list.as_ref().map_or(text_items.len(), |(_, len)| *len);
961965
962966 if body.is_empty() {
963967 return Ok(Vec::new());
964968 }
965− // An empty list renders the @else body
966− if items.is_empty() {
969+ if count == 0 {
967970 return Ok(self.eval_scoped(empty, ctx));
968971 }
969−
970972 let saved_vars = ctx.variables.clone();
971− let mut all_nodes = Vec::new();
972−
973− let has_extra_vars = var_names.len() > 2
974− || (var_names.len() == 2 && items.first().is_some_and(|it| it.contains(' ')));
975−
976− for (i, item) in items.iter().enumerate() {
977− // Always expose $_index for the current iteration
978− ctx.variables.insert("_index".to_string(), i.to_string());
979− if has_extra_vars {
980− // Destructuring: split item by spaces and assign to each variable
981− let parts: Vec<&str> = item.splitn(var_names.len(), ' ').collect();
982− for (vi, vn) in var_names.iter().enumerate() {
983− let val = parts.get(vi).unwrap_or(&"").to_string();
984− ctx.variables.insert(vn.clone(), val);
985− }
986− } else {
987− ctx.variables.insert(var_name.clone(), item.clone());
988− if let Some(ref idx_name) = index_var {
989− ctx.variables.insert(idx_name.clone(), i.to_string());
973+ let mut nodes = Vec::new();
974+ for i in 0..count {
975+ match &data_list {
976+ Some((name, _)) => bind_item(&mut ctx.variables, &format!("{}.{}", name, i), item),
977+ None => {
978+ let text = text_items.get(i).cloned().unwrap_or_default();
979+ ctx.variables.insert(item.to_string(), text);
990980 }
991981 }
992− all_nodes.extend(self.eval_block(body, ctx));
982+ if let Some(index) = index {
983+ ctx.variables.insert(index.to_string(), i.to_string());
984+ }
985+ nodes.extend(self.eval_block(body, ctx));
993986 }
994−
995987 ctx.variables = saved_vars;
996− Ok(all_nodes)
988+ Ok(nodes)
997989 }
998990
999991 fn define_function(
@@ -1656,7 +1648,7 @@ fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<Strin
16561648 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
16571649 let mut best: Option<String> = None;
16581650 let mut best_dist = usize::MAX;
1659− for name in vars.keys() {
1651+ for name in vars.keys().filter(|k| !k.ends_with('#')) {
16601652 let nlen = name.chars().count();
16611653 if nlen.abs_diff(input_chars.len()) > max_allowed {
16621654 continue;
@@ -2498,6 +2490,41 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
24982490
24992491/// Inclusive integer range from `start` to `end` (counting down when
25002492/// `start > end`), stepping by `step`. Stops instead of overflowing.
2493+/// The items of a text list: a range `A..B [step N]`, or comma-separated.
2494+fn text_list_items(list: &str) -> Vec<String> {
2495+ if let Some((start, rest)) = list.split_once("..") {
2496+ let (end, step) = match rest.split_once(" step ") {
2497+ Some((end, step)) => (end.trim(), step.trim().parse::<i64>().unwrap_or(1).max(1)),
2498+ None => (rest.trim(), 1),
2499+ };
2500+ if let (Ok(start), Ok(end)) = (start.trim().parse::<i64>(), end.parse::<i64>()) {
2501+ return numeric_range(start, end, step);
2502+ }
2503+ }
2504+ list.split(',')
2505+ .map(|s| s.trim().to_string())
2506+ .filter(|s| !s.is_empty())
2507+ .collect()
2508+}
2509+
2510+/// Make `$target` (and `$target.key`, and nested lists) a copy of the list
2511+/// item stored under `source`.
2512+fn bind_item(vars: &mut HashMap<String, String>, source: &str, target: &str) {
2513+ let under = |key: &str, name: &str| {
2514+ key.strip_prefix(name)
2515+ .is_some_and(|rest| rest.is_empty() || rest.starts_with(['.', '#']))
2516+ };
2517+ vars.retain(|key, _| !under(key, target));
2518+ let copies: Vec<(String, String)> = vars
2519+ .iter()
2520+ .filter(|(key, _)| under(key, source))
2521+ .map(|(key, value)| (format!("{}{}", target, &key[source.len()..]), value.clone()))
2522+ .collect();
2523+ vars.extend(copies);
2524+ // A record has no text of its own
2525+ vars.entry(target.to_string()).or_default();
2526+}
2527+
25012528fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
25022529 let mut items = Vec::new();
25032530 let mut n = start;
@@ -3029,8 +3056,7 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
30293056 && let Some(close) = matching_brace(after)
30303057 {
30313058 let source = &after[1..close];
3032− let resolve = |name: &str| vars.get(name).cloned();
3033− match crate::expr::eval(source, &resolve) {
3059+ match crate::expr::eval(source, &|name: &str| lookup(vars, name)) {
30343060 Ok(value) => result.push_str(&value.to_string()),
30353061 Err(_) => result.push_str(&rest[pos..pos + 1 + close + 1]),
30363062 }
@@ -3541,36 +3567,15 @@ fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, Stri
35413567 }
35423568 }
35433569 JsonValue::Array(items) => {
3544− vars.insert(format!("{}._count", prefix), items.len().to_string());
3545− // Check if all items are objects with the same keys
3546− let all_objects = items.iter().all(|v| matches!(v, JsonValue::Object(_)));
3547− if all_objects && !items.is_empty() {
3548− // Collect keys from first object for destructuring
3549− if let JsonValue::Object(first_pairs) = &items[0] {
3550− let keys: Vec<String> = first_pairs.iter().map(|(k, _)| k.clone()).collect();
3551− vars.insert(format!("{}._keys", prefix), keys.join(","));
3552− }
3553− // Each item becomes space-separated values, items comma-separated
3554− let csv: Vec<String> = items
3555− .iter()
3556− .map(|item| {
3557− if let JsonValue::Object(pairs) = item {
3558− pairs
3559− .iter()
3560− .map(|(_, v)| json_value_to_string(v))
3561− .collect::<Vec<_>>()
3562− .join(" ")
3563− } else {
3564− json_value_to_string(item)
3565− }
3566− })
3567− .collect();
3568− vars.insert(prefix.to_string(), csv.join(","));
3569− } else {
3570− // Primitive array: comma-separated
3571− let csv: Vec<String> = items.iter().map(json_value_to_string).collect();
3572− vars.insert(prefix.to_string(), csv.join(","));
3573− }
3570+ // A list: its length under `NAME#` (see `lookup`), its text
3571+ // items joined as its value, and each item as `NAME.INDEX`
3572+ vars.insert(format!("{}#", prefix), items.len().to_string());
3573+ let text: Vec<String> = items
3574+ .iter()
3575+ .map(json_value_to_string)
3576+ .filter(|s| !s.is_empty())
3577+ .collect();
3578+ vars.insert(prefix.to_string(), text.join(", "));
35743579 // Also set indexed access: prefix.0, prefix.1, etc.
35753580 for (i, item) in items.iter().enumerate() {
35763581 flatten_json(&format!("{}.{}", prefix, i), item, vars);
@@ -3579,6 +3584,20 @@ fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, Stri
35793584 }
35803585}
35813586
3587+/// Resolve `$name` for an expression: a list if `name` was loaded from a
3588+/// JSON array, else its text.
3589+fn lookup(vars: &HashMap<String, String>, name: &str) -> Option<crate::expr::Value> {
3590+ use crate::expr::Value;
3591+ match vars.get(&format!("{}#", name)).and_then(|n| n.parse::<usize>().ok()) {
3592+ Some(len) => Some(Value::List(
3593+ (0..len)
3594+ .map(|i| vars.get(&format!("{}.{}", name, i)).cloned().unwrap_or_default())
3595+ .collect(),
3596+ )),
3597+ None => vars.get(name).cloned().map(Value::Str),
3598+ }
3599+}
3600+
35823601fn json_value_to_string(v: &JsonValue) -> String {
35833602 match v {
35843603 JsonValue::Str(s) => s.clone(),
editors/vscode/snippets/htmlang.json+4−3
@@ -67,9 +67,10 @@
6767 " @row [spacing ${4:20}, align-items center]",
6868 " @text [bold, color ${5:white}, size ${6:18}] ${7:Logo}",
6969 " @el [width fill]",
70− " @each \\$name, \\$url in ${8:Home /, About /about, Contact /contact}",
71− " @link \\$url",
72− " @text [color ${9:#a8b2d1}, hover:color white] \\$name",
70+ " @data \\$links [{\"label\": \"${8:Home}\", \"url\": \"/\"}, {\"label\": \"About\", \"url\": \"/about\"}]",
71+ " @each \\$link in \\$links",
72+ " @link \\$link.url",
73+ " @text [color ${9:#a8b2d1}, hover:color white] \\$link.label",
7374 " $0"
7475 ],
7576 "description": "Navigation bar with logo and links"
examples/demo.hl+8−3
@@ -135,10 +135,15 @@
135135 -- Control flow: @each, @else, @match, if()
136136 @titled [title Control flow]
137137 @column [spacing 6]
138− @each $name, $role in Alice Engineer, Bob Designer, Carol PM
138+ @data $team [
139+ {"name": "Alice", "role": "Engineer"},
140+ {"name": "Bob", "role": "Designer"},
141+ {"name": "Carol", "role": "PM"}
142+ ]
143+ @each $person in $team
139144 @row [spacing 10, align-items baseline]
140− @text [bold, min-width 80] $name
141− @text [color $muted] $role
145+ @text [bold, min-width 80] $person.name
146+ @text [color $muted] $person.role
142147
143148 -- @each ... @else fallback for empty list
144149 @let nothing ""
src/bin/htmlang_lsp/docs.rs+3−3
@@ -104,10 +104,10 @@ pub(crate) const DIRECTIVES: &[Doc] = &[
104104 doc("else", "Fallback branch of `@if` or `@each`.", "@else\n @text None"),
105105 doc(
106106 "each",
107− "Repeats its body for each item of a list or range; `$_index` counts from 0.",
108− "@each $item in apple, banana\n @text $item\n@each $i in 1..10 step 2\n @text $i",
107+ "Repeats its body for each item of a list or range; an optional second variable is the index, from 0. Records from `@data` have fields `$item.key`.",
108+ "@each $item, $i in apple, banana\n @text $i: $item\n@each $post in $posts\n @text $post.title",
109109 ),
110− doc("data", "Loads data as variables: a JSON file, a glob of them, or `env:NAME`.", "@data $site site.json"),
110+ doc("data", "Loads data as variables: a JSON file, inline JSON, a glob of files (a list of records), or `env:NAME`.", "@data $site site.json\n@data $links [{\"label\": \"Home\", \"url\": \"/\"}]"),
111111];
112112
113113/// htmlang's own attributes, plus CSS properties htmlang treats specially.
src/upgrade.rs+169−1
@@ -267,13 +267,163 @@ pub fn upgrade(input: &str) -> Upgrade {
267267 output.push('\n');
268268 }
269269 let (output, unaliased) = drop_include_aliases(&output);
270+ let (output, data_changes) = upgrade_lists(&output, &mut manual);
270271 Upgrade {
271272 output,
272− changes: changes + unaliased,
273+ changes: changes + unaliased + data_changes,
273274 manual,
274275 }
275276}
276277
278+/// Lists and loops, on the otherwise upgraded text (line numbers in notes
279+/// are the upgraded file's):
280+/// - `@each $label, $url in Home /, About /about` (items split on spaces)
281+/// → inline records: `@data $label-url [{"label": "Home", ...}]` and
282+/// `@each $item in $label-url` with `$item.label`
283+/// - `$_index` → a named index: `@each $x, $index in ...`
284+/// - `$posts._count` → `length($posts)`
285+fn upgrade_lists(text: &str, manual: &mut Vec<(usize, String)>) -> (String, usize) {
286+ let mut lines: Vec<String> = text.split('\n').map(String::from).collect();
287+ let mut changes = 0;
288+ let mut i = 0;
289+ while i < lines.len() {
290+ let line = lines[i].clone();
291+ let trimmed = line.trim_start();
292+ let pad = &line[..line.len() - trimmed.len()];
293+ if let Some(count_line) = replace_counts(&line) {
294+ lines[i] = count_line;
295+ changes += 1;
296+ continue;
297+ }
298+ if let Some(rest) = trimmed.strip_prefix("@data $")
299+ && let Some((name, source)) = rest.split_once(' ')
300+ && source.contains('*')
301+ && text.contains(&format!("${}.", name))
302+ {
303+ manual.push((
304+ i + 1,
305+ format!(
306+ "`${name}` is now a list with one record per file: use `@each $item in ${name}` \
307+ and `$item.key` (`$item.file` is the file name); `${name}.STEM.key` no longer works"
308+ ),
309+ ));
310+ }
311+ let Some((names, list)) = trimmed.strip_prefix("@each ").and_then(|r| r.split_once(" in "))
312+ else {
313+ i += 1;
314+ continue;
315+ };
316+ let vars: Vec<String> =
317+ names.split(',').map(|v| v.trim().trim_start_matches('$').to_string()).collect();
318+ let list = list.trim().to_string();
319+ let lines_ref: Vec<&str> = lines.iter().map(String::as_str).collect();
320+ let end = block_end(&lines_ref, i);
321+ let uses_index = lines[i + 1..end].iter().any(|l| replace_var(l, "_index", "\0") != *l);
322+ let items: Vec<&str> = list.split(',').map(str::trim).filter(|s| !s.is_empty()).collect();
323+ let literal = !list.starts_with('$') && !list.contains("..");
324+ let destructures = vars.len() > 2 || items.iter().any(|item| item.contains(' '));
325+
326+ if vars.len() >= 2 && literal && destructures {
327+ let json_text = |s: &str| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
328+ let records: Vec<String> = items
329+ .iter()
330+ .map(|item| {
331+ let parts: Vec<&str> = item.splitn(vars.len(), ' ').collect();
332+ let fields: Vec<String> = vars
333+ .iter()
334+ .enumerate()
335+ .map(|(k, var)| {
336+ format!("{}: {}", json_text(var), json_text(parts.get(k).unwrap_or(&"")))
337+ })
338+ .collect();
339+ format!("{{{}}}", fields.join(", "))
340+ })
341+ .collect();
342+ let data = vars.join("-");
343+ let index = if uses_index { ", $index" } else { "" };
344+ let mut new = vec![
345+ format!("{pad}@data ${} [{}]", data, records.join(", ")),
346+ format!("{pad}@each $item{} in ${}", index, data),
347+ ];
348+ for body_line in &lines[i + 1..end] {
349+ let mut body_line = replace_var(body_line, "_index", "$index");
350+ for var in &vars {
351+ body_line = replace_var(&body_line, var, &format!("$item.{}", var));
352+ }
353+ new.push(body_line);
354+ }
355+ let added = new.len();
356+ lines.splice(i..end, new);
357+ changes += 1;
358+ i += added;
359+ continue;
360+ }
361+ if vars.len() > 2 || (vars.len() == 2 && list.starts_with('$')) {
362+ manual.push((
363+ i + 1,
364+ format!(
365+ "the second variable of `@each` is now always the index: if `{}` holds \
366+ records, write `@each $item in {}` and use `$item.KEY`",
367+ list, list
368+ ),
369+ ));
370+ }
371+ if uses_index {
372+ let index = match vars.get(1) {
373+ Some(index) => index.clone(),
374+ None => {
375+ lines[i] = format!("{pad}@each ${}, $index in {}", vars[0], list);
376+ "index".to_string()
377+ }
378+ };
379+ for body_line in &mut lines[i + 1..end] {
380+ *body_line = replace_var(body_line, "_index", &format!("${}", index));
381+ }
382+ changes += 1;
383+ }
384+ i += 1;
385+ }
386+ (lines.join("\n"), changes)
387+}
388+
389+/// `$posts._count` → `length($posts)` in an expression, `${length($posts)}`
390+/// elsewhere. `None` when the line has none.
391+fn replace_counts(line: &str) -> Option<String> {
392+ if !line.contains("._count") {
393+ return None;
394+ }
395+ let trimmed = line.trim_start();
396+ let expression_line = ["@if ", "@else if "].iter().any(|p| trimmed.starts_with(p))
397+ || trimmed
398+ .strip_prefix("@let ")
399+ .and_then(|r| r.split_once(' '))
400+ .is_some_and(|(_, v)| v.trim_start().starts_with('='));
401+ let is_name = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
402+ let mut out = String::new();
403+ let mut rest = line;
404+ // Inside `${...}`: already an expression
405+ let mut depth = 0usize;
406+ while let Some(pos) = rest.find('$') {
407+ depth = depth.saturating_sub(rest[..pos].matches('}').count());
408+ out.push_str(&rest[..pos]);
409+ let after = &rest[pos + 1..];
410+ if after.starts_with('{') {
411+ depth += 1;
412+ }
413+ let end = after.find(|c: char| !is_name(c)).unwrap_or(after.len());
414+ match after[..end].strip_suffix("._count") {
415+ Some(name) if expression_line || depth > 0 => {
416+ out.push_str(&format!("length(${})", name))
417+ }
418+ Some(name) => out.push_str(&format!("${{length(${})}}", name)),
419+ None => out.push_str(&rest[pos..pos + 1 + end]),
420+ }
421+ rest = &after[end..];
422+ }
423+ out.push_str(rest);
424+ Some(out)
425+}
426+
277427/// `@include lib.hl as ui` → `@include lib.hl`, and `@ui.card` / `$ui.x`
278428/// lose their `ui.` prefix.
279429fn drop_include_aliases(text: &str) -> (String, usize) {
@@ -1676,6 +1826,24 @@ mod tests {
16761826 );
16771827 }
16781828
1829+ #[test]
1830+ fn loops_and_lists() {
1831+ assert_eq!(
1832+ up("@each $label, $url in About us /about, Home /\n @link $url $label\n"),
1833+ "@data $label-url [{\"label\": \"About\", \"url\": \"us /about\"}, {\"label\": \"Home\", \"url\": \"/\"}]\n@each $item in $label-url\n @link $item.url $item.label\n"
1834+ );
1835+ assert_eq!(
1836+ up("@each $x in a, b\n @text $_index: $x\n"),
1837+ "@each $x, $index in a, b\n @text $index: $x\n"
1838+ );
1839+ assert_eq!(
1840+ up("@if $posts._count > 0\n @text ${$posts._count} posts\n"),
1841+ "@if length($posts) > 0\n @text ${length($posts)} posts\n"
1842+ );
1843+ let result = super::upgrade("@data $p posts/*.json\n@text $p.a.title\n@each $a, $b in $list\n x\n");
1844+ assert_eq!(result.manual.len(), 2, "{:?}", result.manual);
1845+ }
1846+
16791847 #[test]
16801848 fn raw_strings_become_indented_bodies() {
16811849 assert_eq!(up("@raw \"\"\"<br>\"\"\"\n"), "@raw <br>\n");
tests/regressions.rs+4−4
@@ -355,17 +355,17 @@ fn htmlang_attributes_sharing_html_names_do_not_warn() {
355355}
356356
357357#[test]
358−fn data_glob_loads_each_file_under_its_stem() {
358+fn data_glob_loads_a_list_of_records() {
359359 let dir = scratch_dir("data_glob");
360360 std::fs::create_dir_all(dir.join("posts")).unwrap();
361361 std::fs::write(dir.join("posts/a.json"), r#"{"title": "First"}"#).unwrap();
362362 std::fs::write(dir.join("posts/b.json"), r#"{"title": "Second"}"#).unwrap();
363363 let out = compile_in(
364364 &dir,
365− "@data $posts posts/*.json\n@text $posts._count: $posts.a.title, $posts.b.title\n@each $p in $posts\n @text [class=item] $p",
365+ "@data $posts posts/*.json\n@text ${length($posts)} posts\n@each $p, $i in $posts\n @text $i $p.title ($p.file)",
366366 );
367− assert!(out.contains("2: First, Second"), "{}", out);
368− assert!(out.contains(">a<") && out.contains(">b<"), "{}", out);
367+ assert!(out.contains("2 posts"), "{}", out);
368+ assert!(out.contains(">0 First (a)<") && out.contains(">1 Second (b)<"), "{}", out);
369369}
370370
371371#[test]
tests/snapshots.rs+19−16
@@ -1396,10 +1396,21 @@ fn let_string_interpolation() {
13961396// ---------------------------------------------------------------------------
13971397
13981398#[test]
1399−fn each_destructuring_pairs() {
1400− let output = compile("@each $name, $role in Alice Admin, Bob User\n @text $name is $role");
1401− assert!(output.contains("Alice is Admin"));
1402− assert!(output.contains("Bob is User"));
1399+fn each_binds_records() {
1400+ let output = compile(
1401+ "@data $people [{\"name\": \"Alice Smith\", \"role\": \"Admin, Owner\"}, {\"name\": \"Bob\"}]\n@each $p in $people\n @text $p.name is $p.role\n @each $x in $p.missing\n @text never",
1402+ );
1403+ assert!(output.contains("Alice Smith is Admin, Owner"), "{}", output);
1404+ assert!(output.contains("Bob is"), "{}", output);
1405+ assert!(!output.contains("never"), "{}", output);
1406+}
1407+
1408+#[test]
1409+fn each_second_variable_is_the_index() {
1410+ let output = compile("@each $item, $i in New York, Paris\n @text $i=$item");
1411+ assert!(output.contains("0=New York") && output.contains("1=Paris"), "{}", output);
1412+ let diags = parse_diagnostics("@each $a, $b, $c in x\n @text $a");
1413+ assert!(diags.iter().any(|d| d.message.contains("`$item, $index`")), "{:?}", diags);
14031414}
14041415
14051416// ---------------------------------------------------------------------------
@@ -4104,10 +4115,10 @@ fn test_warning_unused_function() {
41044115
41054116#[test]
41064117fn test_each_index_variable() {
4107− let html = compile("@each $item in A, B, C\n @text $_index");
4108− assert!(html.contains(">0<"), "first item should have $_index = 0");
4109− assert!(html.contains(">1<"), "second item should have $_index = 1");
4110− assert!(html.contains(">2<"), "third item should have $_index = 2");
4118+ let html = compile("@each $item, $i in A, B, C\n @text $i");
4119+ assert!(html.contains(">0<"), "first item should have index 0");
4120+ assert!(html.contains(">1<"), "second item should have index 1");
4121+ assert!(html.contains(">2<"), "third item should have index 2");
41114122}
41124123
41134124#[test]
@@ -4426,14 +4437,6 @@ fn parser_multiple_errors() {
44264437 );
44274438}
44284439
4429−#[test]
4430−fn repeat_with_index() {
4431− let output = compile("@each $_ in 1..3\n @text $_index");
4432− assert!(output.contains("0"), "should have index 0");
4433− assert!(output.contains("1"), "should have index 1");
4434− assert!(output.contains("2"), "should have index 2");
4435−}
4436−
44374440// ---------------------------------------------------------------------------
44384441// New feature tests
44394442// ---------------------------------------------------------------------------
tests/snapshots/data_directive.hl+3−2
@@ -7,5 +7,6 @@
77@each $tag in $site.tags
88 @text [color blue] $tag
99
10−@each $label, $url in $site.links
11− @link $url $label
10+@text ${length($site.links)} links
11+@each $link in $site.links
12+ @link $link.url $link.label
tests/snapshots/data_directive.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Data Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@layer htmlang{.a{font-weight:bold;font-size:24px;}.b{color:blue;}}</style></head><body><span class="a">My Site</span><span>Version: 1.0</span><span class="b">rust</span><span class="b">html</span><span class="b">css</span><a href="/">Home</a><a href="/about">About</a><a href="/contact">Contact</a></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Data Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@layer htmlang{.a{font-weight:bold;font-size:24px;}.b{color:blue;}}</style></head><body><span class="a">My Site</span><span>Version: 1.0</span><span class="b">rust</span><span class="b">html</span><span class="b">css</span><span>3 links</span><a href="/">Home</a><a href="/about">About</a><a href="/contact">Contact</a></body></html>
tests/snapshots/each_destructuring.hl+4−3
@@ -1,6 +1,7 @@
11@page Destructuring
22
3−@each $name, $role in Alice Admin, Bob User, Carol Editor
3+@data $name-role [{"name": "Alice", "role": "Admin"}, {"name": "Bob", "role": "User"}, {"name": "Carol", "role": "Editor"}]
4+@each $item in $name-role
45 @row [spacing 10]
5− @text [bold] $name
6− @text $role
6+ @text [bold] $item.name
7+ @text $item.role
tests/snapshots/repeat_directive.hl+2−2
@@ -1,2 +1,2 @@
1−@each $_ in 1..3
2− @text [bold] Item $_index
1+@each $_, $index in 1..3
2+ @text [bold] Item $index
tests/snapshots/stress_large.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Stress Test Large Document</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}:root{--primary:#3b82f6;--secondary:#10b981;--accent:#f59e0b;--danger:#ef4444;--dark:#1e293b;--light:#f8fafc;--gray:#64748b;--border:#e2e8f0;--radius:8;--gap:16;--gap-sm:8;--gap-lg:24;}@layer htmlang{.a{display:flex;flex-direction:row;gap:0;min-height:100vh;}.b{display:flex;flex-direction:column;width:260px;background:white;border-right:1px solid var(--border);padding:16px;gap:8px;}.c{display:flex;flex-direction:row;gap:12px;padding:8px;align-items:center;}.d{flex-direction:column;width:36px;height:36px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;}.e{display:flex;flex-direction:column;gap:2px;}.f{font-weight:bold;font-size:16px;color:var(--dark);}.g{font-size:12px;color:var(--gray);}.h{display:flex;flex-direction:column;width:100%;height:1px;background:var(--border);margin:8px 0;}.i{display:flex;flex-direction:column;margin-bottom:24px;}.j{font-weight:bold;font-size:12px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;}.k{color:var(--primary);font-weight:bold;padding:8px 12px;background:#eff6ff;border-radius:6px;}.l{color:var(--gray);padding:8px 12px;border-radius:6px;transition:all 0.15s;}.m{display:flex;flex-direction:column;gap:4px;}.n{display:flex;flex-direction:row;gap:8px;align-items:center;}.o{display:flex;flex-direction:column;width:6px;height:6px;border-radius:50%;background:var(--secondary);}.p{font-size:13px;color:var(--gray);}.q{display:flex;flex-direction:column;width:6px;height:6px;border-radius:50%;background:var(--accent);}.r{display:flex;flex-direction:column;width:6px;height:6px;border-radius:50%;background:#cbd5e1;}.s{display:flex;flex-direction:column;margin-top:auto;padding:12px;background:#f1f5f9;border-radius:8px;}.t{flex-direction:column;width:28px;height:28px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;}.u{display:flex;flex-direction:column;gap:0;}.v{font-size:13px;font-weight:bold;color:var(--dark);}.w{font-size:11px;color:var(--gray);}.x{display:flex;flex-direction:column;flex:1;min-width:0;background:var(--light);}.y{display:flex;flex-direction:column;padding:12px 24px;background:white;border-bottom:1px solid var(--border);}.z{display:flex;flex-direction:row;gap:16px;align-items:center;}.aa{display:flex;flex-direction:row;gap:8px;}.ab{color:var(--gray);font-size:14px;}.ac{color:var(--border);font-size:14px;}.ad{color:var(--dark);font-size:14px;}.ae{font-weight:bold;font-size:22px;color:var(--dark);}.af{display:flex;flex-direction:column;flex:1;min-width:0;}.ag{display:flex;flex-direction:row;gap:12px;align-items:center;}.ah{padding:8px 12px;border-radius:6px;border:1px solid var(--border);width:240px;transition:all 0.2s;}.ai{display:flex;flex-direction:column;position:relative;}.aj{padding:8px 16px;background:var(--primary);color:white;border-radius:6px;border:none solid currentColor;cursor:pointer;transition:all 0.2s;font-size:14px;}.ak{display:flex;flex-direction:column;position:absolute;top:-2px;right:-2px;}.al{display:flex;flex-direction:column;width:8px;height:8px;border-radius:50%;background:var(--danger);}.am{padding:8px 16px;background:var(--gray);color:white;border-radius:6px;border:none solid currentColor;cursor:pointer;transition:all 0.2s;font-size:14px;}.an{display:flex;flex-direction:column;padding:24px;overflow-y:auto;}.ao{display:flex;flex-direction:column;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:12px 16px;}.ap{color:#1e40af;font-size:14px;}.aq,.av{display:flex;flex-direction:row;gap:16px;margin-top:24px;}.ar{display:flex;flex-direction:column;background:white;border-radius:8px;padding:16px;border:1px solid var(--border);}.as{font-size:14px;color:var(--gray);}.at{font-weight:bold;font-size:28px;color:var(--dark);margin-top:4px;}.au{font-size:12px;color:var(--secondary);margin-top:2px;}.aw{display:flex;flex-direction:column;background:white;border-radius:8px;padding:16px;border:1px solid var(--border);transition:all 0.2s;animation:fade-in 0.3s ease;}.ax{font-weight:bold;font-size:18px;color:var(--dark);}.ay{display:flex;flex-direction:column;margin-top:8px;}.az{flex-direction:column;height:200px;background:var(--light);border-radius:6px;margin-top:12px;display:flex;align-items:flex-end;padding:16px;gap:8px;}.a0{display:flex;flex-direction:column;width:100%;height:40%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a1{display:flex;flex-direction:column;width:100%;height:65%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a2{display:flex;flex-direction:column;width:100%;height:45%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a3{display:flex;flex-direction:column;width:100%;height:80%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a4{display:flex;flex-direction:column;width:100%;height:55%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a5{display:flex;flex-direction:column;width:100%;height:70%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a6{display:flex;flex-direction:column;width:100%;height:90%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a7{display:flex;flex-direction:column;width:100%;height:60%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a8{display:flex;flex-direction:column;width:100%;height:75%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a9{display:flex;flex-direction:column;width:100%;height:85%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.ba{display:flex;flex-direction:column;width:100%;height:50%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.bb{display:flex;flex-direction:column;width:100%;height:95%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.bc{display:flex;flex-direction:column;gap:12px;margin-top:12px;}.bd{display:flex;flex-direction:row;gap:12px;align-items:center;padding:8px;border-radius:6px;}.be{flex-direction:column;width:32px;height:32px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;}.bf{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0;}.bg{font-size:14px;font-weight:bold;color:var(--dark);}.bh{display:flex;flex-direction:column;flex:1;min-width:0;gap:16px;}.bi{display:flex;flex-direction:column;gap:0;margin-top:12px;}.bj{display:flex;flex-direction:row;gap:0;padding:8px 16px;background:var(--light);border-bottom:1px solid var(--border);}.bk{display:flex;flex-direction:column;width:200px;}.bl{font-weight:bold;font-size:12px;color:var(--gray);text-transform:uppercase;}.bm{display:flex;flex-direction:column;width:150px;}.bn{display:flex;flex-direction:column;width:120px;}.bo{display:flex;flex-direction:row;gap:0;padding:12px 16px;border-bottom:1px solid var(--border);}.bp{font-size:14px;}.bq{display:flex;flex-direction:row;gap:0;}.br{font-size:13px;color:var(--dark);flex:1;min-width:0;}.bs{display:flex;flex-direction:column;width:100%;height:8px;background:var(--border);border-radius:4px;overflow:hidden;}.bt{display:flex;flex-direction:column;width:67%;height:100%;background:var(--primary);border-radius:4px;}.bu{display:flex;flex-direction:column;gap:8px;margin-top:12px;}.bv{display:flex;flex-direction:row;gap:12px;padding:8px 12px;align-items:center;}.bw{flex-direction:column;width:24px;height:24px;border-radius:50%;background:#eff6ff;display:flex;align-items:center;justify-content:center;}.bx{font-size:10px;color:var(--primary);font-weight:bold;}.by{font-size:14px;color:var(--dark);flex:1;min-width:0;}.bz{display:flex;flex-direction:column;width:340px;gap:16px;}.b0{padding:8px 16px;background:var(--secondary);color:white;border-radius:6px;border:none solid currentColor;cursor:pointer;transition:all 0.2s;font-size:14px;}.b1{padding:8px 16px;background:var(--accent);color:white;border-radius:6px;border:none solid currentColor;cursor:pointer;transition:all 0.2s;font-size:14px;}.b2{padding:8px 16px;background:var(--gray);color:white;border-radius:6px;border:none solid currentColor;cursor:pointer;transition:all 0.2s;font-size:14px;}.b3{display:flex;flex-direction:row;gap:10px;align-items:center;padding:6px 8px;border-radius:6px;transition:all 0.15s;}.b4{display:flex;flex-direction:column;gap:0;flex:1;min-width:0;}.b5{display:flex;flex-direction:column;width:8px;height:8px;border-radius:50%;background:var(--secondary);}.b6{display:flex;flex-direction:row;gap:0;flex-wrap:wrap;margin-top:8px;}.b7{flex-direction:column;display:inline-block;padding:4px 10px;background:#f1f5f9;color:var(--gray);border-radius:4px;font-size:12px;margin:2px;}.b8{display:flex;flex-direction:row;gap:10px;padding:10px 12px;border-bottom:1px solid var(--border);align-items:center;}.b9{display:flex;flex-direction:row;gap:0;align-items:center;}.ca{display:flex;flex-direction:column;width:40px;height:22px;border-radius:11px;background:var(--primary);padding:2px;cursor:pointer;transition:all 0.2s;}.cb{display:flex;flex-direction:column;width:18px;height:18px;border-radius:50%;background:white;margin-left:18px;transition:all 0.2s;}.cc{display:flex;flex-direction:column;width:40px;height:22px;border-radius:11px;background:#cbd5e1;padding:2px;cursor:pointer;transition:all 0.2s;}.cd{display:flex;flex-direction:column;width:18px;height:18px;border-radius:50%;background:white;transition:all 0.2s;}.ce{display:flex;flex-direction:column;margin-top:24px;}.cf{display:flex;flex-direction:row;gap:16px;margin-bottom:12px;align-items:center;}.cg{padding:8px 12px;border-radius:6px;border:1px solid var(--border);width:240px;}.ch{padding:6px 12px;border-radius:4px;border:1px solid var(--border);background:white;color:var(--gray);font-size:13px;cursor:pointer;transition:all 0.15s;}.ci{display:flex;flex-direction:row;gap:0;padding:8px 16px;background:var(--light);border-bottom:2px solid var(--border);border-radius:6 6 0 0;}.cj{display:flex;flex-direction:column;width:60px;}.ck{display:flex;flex-direction:row;gap:0;padding:10px 16px;border-bottom:1px solid var(--border);cursor:pointer;transition:all 0.1s;}.cl{flex-direction:column;width:24px;height:24px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;}.cm{font-size:14px;color:var(--dark);}.cn{flex-direction:column;display:inline-block;padding:2px 8px;background:var(--secondary);color:white;border-radius:12px;font-size:12px;}.co{display:flex;flex-direction:row;gap:16px;padding:12px 16px;align-items:center;border-top:1px solid var(--border);}.cp{display:flex;flex-direction:row;gap:4px;}.cq{padding:6px 10px;border-radius:4px;border:1px solid var(--border);background:white;color:var(--gray);font-size:13px;cursor:pointer;}.cr{padding:6px 10px;border-radius:4px;border:1px solid var(--primary);background:var(--primary);color:white;font-size:13px;cursor:pointer;}.cs{display:flex;flex-direction:column;padding:24px 24px;background:white;border-top:1px solid var(--border);}.ct{display:flex;flex-direction:row;gap:24px;}.cu{display:flex;flex-direction:column;gap:8px;flex:1;min-width:0;}.cv{font-weight:bold;font-size:14px;color:var(--dark);}.cw{font-weight:bold;font-size:12px;color:var(--gray);text-transform:uppercase;margin-bottom:4px;}.cx{color:var(--gray);font-size:13px;}.l:hover{color:var(--primary);background:#f1f5f9;}.ab:hover,.cx:hover{color:var(--primary);}.aj:hover,.am:hover,.b0:hover,.b1:hover,.b2:hover{background:var(--dark);}.aw:hover{box-shadow:0 4 12 rgba(0,0,0,0.1);}.a0:hover,.a1:hover,.a2:hover,.a3:hover,.a4:hover,.a5:hover,.a6:hover,.a7:hover,.a8:hover,.a9:hover,.ba:hover,.bb:hover{background:#2563eb;}.bd:hover,.bo:hover,.ck:hover{background:var(--light);}.b3:hover{background:#f1f5f9;}.ch:hover{background:#f1f5f9;color:var(--dark);}.ah:focus{border:1px solid var(--primary);}@media(min-width:640px){.aq,.ct{flex-direction:column;}.bz{flex:1;min-width:0;}}@media(min-width:1024px){.aq,.ct{flex-direction:row;}}@media(prefers-color-scheme:dark){.b{background:#0f172a;border-right:1px solid var(--dark);}.f,.v,.ad,.ae,.at,.ax,.bg,.br,.by,.cm,.cv{color:white;}.h,.bs{background:#334155;}.k,.bw{background:#1e3a5f;}.s,.ch,.cq{background:var(--dark);}.x,.az,.bj,.ci{background:#0f172a;}.y{background:#0f172a;border-bottom:1px solid var(--dark);}.ah{border:1px solid #334155;background:var(--dark);color:white;}.ar,.aw{background:var(--dark);border:1px solid #334155;}.bo,.b8,.ck{border-bottom:1px solid #334155;}.b7{background:#334155;color:#94a3b8;}.cc{background:#475569;}.cs{background:#0f172a;border-top:1px solid var(--dark);}}}@keyframes fade-in {from{opacity:0}to{opacity:1}}@keyframes slide-down {0%{transform:translateY(-10px);opacity:0}100%{transform:translateY(0);opacity:1}}</style></head><body><div class="a"><div class="b"><div class="c"><div class="d"><span>HL</span></div><div class="e"><span class="f">htmlang</span><span class="g">Dashboard v2.0</span></div></div><div class="h"></div><div class="i"><span class="j">Navigation</span><div class="e"><a href="/dashboard" class="k">Dashboard</a><a href="/analytics" class="l">Analytics</a><a href="/projects" class="l">Projects</a><a href="/team" class="l">Team</a><a href="/messages" class="l">Messages</a><a href="/calendar" class="l">Calendar</a><a href="/documents" class="l">Documents</a><a href="/settings" class="l">Settings</a></div></div><div class="i"><span class="j">Favorites</span><div class="e"><a href="/project/alpha" class="l">Project Alpha</a><a href="/project/beta" class="l">Project Beta</a><a href="/reports/q4" class="l">Q4 Reports</a></div></div><div class="i"><span class="j">Shortcuts</span><div class="m"><div class="n"><div class="o"></div><span class="p">Online (12)</span></div><div class="n"><div class="q"></div><span class="p">Away (3)</span></div><div class="n"><div class="r"></div><span class="p">Offline (5)</span></div></div></div><div class="s"><div class="n"><div class="t"><span>JD</span></div><div class="u"><span class="v">Jane Doe</span><span class="w">jane@example.com</span></div></div></div></div><div class="x"><header class="y"><div class="z"><div class="e"><div class="aa"><div class="aa"><a href="/" class="ab">Home</a><span class="ac">/</span></div><span class="ad">Dashboard</span></div><span class="ae">Dashboard Overview</span></div><div class="af"></div><div class="ag"><input class="ah" type="search" placeholder="Search..."><div class="ai"><button class="aj">Alerts</button><div class="ak"><div class="al"></div></div></div><button class="am">Settings</button></div></div></header><div class="an"><div class="ao"><span class="ap">Welcome back! You have 3 new notifications and 2 pending tasks.</span></div><div class="aq"><div class="ar"><span class="as">Total Revenue</span><span class="at">$48</span><span class="au">+12.5% from last month</span></div><div class="ar"><span class="as">Active Users</span><span class="at">2</span><span class="au">+8.2% from last week</span></div><div class="ar"><span class="as">Conversion Rate</span><span class="at">3.24%</span><span class="au">+0.4% from yesterday</span></div><div class="ar"><span class="as">Open Tickets</span><span class="at">142</span><span class="au">-5.1% from last month</span></div></div><div class="av"><div class="aw"><span class="ax">Revenue Overview</span><div class="ay"><div class="az"><div class="a0"></div><div class="a1"></div><div class="a2"></div><div class="a3"></div><div class="a4"></div><div class="a5"></div><div class="a6"></div><div class="a7"></div><div class="a8"></div><div class="a9"></div><div class="ba"></div><div class="bb"></div></div></div></div><div class="aw"><span class="ax">Activity Feed</span><div class="ay"><div class="bc"><div class="bd"><div class="be"><span>Alice</span></div><div class="bf"><span class="bg">Alice</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Bob</span></div><div class="bf"><span class="bg">Bob</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Carol</span></div><div class="bf"><span class="bg">Carol</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Dave</span></div><div class="bf"><span class="bg">Dave</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Eve</span></div><div class="bf"><span class="bg">Eve</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Frank</span></div><div class="bf"><span class="bg">Frank</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Grace</span></div><div class="bf"><span class="bg">Grace</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Hank</span></div><div class="bf"><span class="bg">Hank</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div></div></div></div></div><div class="aq"><div class="bh"><div class="aw"><span class="ax">Project Status</span><div class="ay"><div class="bi"><div class="bj"><div class="bk"><span class="bl">Project</span></div><div class="bm"><span class="bl">Status</span></div><div class="bn"><span class="bl">Progress</span></div><div class="af"><span class="bl">Due Date</span></div></div><div class="bo"><div class="bk"><span class="bp">Project Alpha</span></div><div class="bm"><span class="as">In Progress</span></div><div class="bn"><span class="bp">75%</span></div><div class="af"><span class="as">Dec 15 2025</span></div></div><div class="bo"><div class="bk"><span class="bp">Project Beta</span></div><div class="bm"><span class="as">Review</span></div><div class="bn"><span class="bp">90%</span></div><div class="af"><span class="as">Nov 30 2025</span></div></div><div class="bo"><div class="bk"><span class="bp">Project Gamma</span></div><div class="bm"><span class="as">Planning</span></div><div class="bn"><span class="bp">20%</span></div><div class="af"><span class="as">Jan 20 2026</span></div></div><div class="bo"><div class="bk"><span class="bp">Project Delta</span></div><div class="bm"><span class="as">Completed</span></div><div class="bn"><span class="bp">100%</span></div><div class="af"><span class="as">Oct 10 2025</span></div></div><div class="bo"><div class="bk"><span class="bp">Project Epsilon</span></div><div class="bm"><span class="as">On Hold</span></div><div class="bn"><span class="bp">45%</span></div><div class="af"><span class="as">Feb 28 2026</span></div></div></div></div></div><div class="aw"><span class="ax">Progress Breakdown</span><div class="ay"><div class="bc"><div class="m"><div class="bq"><span class="br">Alpha</span><span class="p">67%</span></div><div class="bs"><div class="bt"></div></div></div><div class="m"><div class="bq"><span class="br">Beta</span><span class="p">67%</span></div><div class="bs"><div class="bt"></div></div></div><div class="m"><div class="bq"><span class="br">Gamma</span><span class="p">67%</span></div><div class="bs"><div class="bt"></div></div></div><div class="m"><div class="bq"><span class="br">Delta</span><span class="p">67%</span></div><div class="bs"><div class="bt"></div></div></div><div class="m"><div class="bq"><span class="br">Epsilon</span><span class="p">67%</span></div><div class="bs"><div class="bt"></div></div></div></div></div></div><div class="aw"><span class="ax">Recent Activity Log</span><div class="ay"><div class="bu"><div class="bv"><div class="bw"><span class="bx">v2.3.1 to production</span></div><span class="by">Deployed</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">feature/auth-redesign</span></div><span class="by">Merged</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">issue #482: Login timeout</span></div><span class="by">Closed</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">CI/CD pipeline config</span></div><span class="by">Updated</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">unit tests for payment module</span></div><span class="by">Added</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">PR #156: API refactor</span></div><span class="by">Reviewed</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">memory leak in worker pool</span></div><span class="by">Fixed</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">hotfix 2.3.2</span></div><span class="by">Released</span><span class="w">just now</span></div></div></div></div></div><div class="bz"><div class="aw"><span class="ax">Quick Actions</span><div class="ay"><div class="bu"><button class="aj">New Project</button><button class="b0">Create Report</button><button class="b1">Invite Member</button><button class="b2">View Backlog</button></div></div></div><div class="aw"><span class="ax">Team Members</span><div class="ay"><div class="bu"><div class="b3"><div class="t"><span>Alice</span></div><div class="b4"><span class="v">Alice</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Bob</span></div><div class="b4"><span class="v">Bob</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Carol</span></div><div class="b4"><span class="v">Carol</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Dave</span></div><div class="b4"><span class="v">Dave</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Eve</span></div><div class="b4"><span class="v">Eve</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Frank</span></div><div class="b4"><span class="v">Frank</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Grace</span></div><div class="b4"><span class="v">Grace</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Hank</span></div><div class="b4"><span class="v">Hank</span><span class="w">Developer</span></div><div class="b5"></div></div></div></div></div><div class="aw"><span class="ax">Tags</span><div class="ay"><div class="b6"><div class="b7"><span>frontend</span></div><div class="b7"><span>backend</span></div><div class="b7"><span>devops</span></div><div class="b7"><span>design</span></div><div class="b7"><span>mobile</span></div><div class="b7"><span>security</span></div><div class="b7"><span>performance</span></div><div class="b7"><span>testing</span></div><div class="b7"><span>docs</span></div><div class="b7"><span>infra</span></div><div class="b7"><span>ux</span></div><div class="b7"><span>api</span></div></div></div></div><div class="aw"><span class="ax">Notifications</span><div class="ay"><div class="bi"><div class="b8"><div class="al"></div><span class="br">New deployment completed</span><span class="w">now</span></div><div class="b8"><div class="al"></div><span class="br">PR review requested</span><span class="w">now</span></div><div class="b8"><div class="al"></div><span class="br">Build failed on staging</span><span class="w">now</span></div><div class="b8"><div class="al"></div><span class="br">Meeting in 30 minutes</span><span class="w">now</span></div><div class="b8"><div class="al"></div><span class="br">Subscription renewal due</span><span class="w">now</span></div></div></div></div><div class="aw"><span class="ax">Settings</span><div class="ay"><div class="bc"><div class="b9"><span class="by">Dark Mode</span><div class="ca"><div class="cb"></div></div></div><div class="b9"><span class="by">Notifications</span><div class="ca"><div class="cb"></div></div></div><div class="b9"><span class="by">Auto-save</span><div class="cc"><div class="cd"></div></div></div><div class="b9"><span class="by">Compact View</span><div class="cc"><div class="cd"></div></div></div></div></div></div></div></div><div class="ce"><div class="aw"><span class="ax">Data Table</span><div class="ay"><div class="bi"><div class="cf"><input class="cg" type="search" placeholder="Filter results..."><div class="af"></div><div class="aa"><button class="ch">all</button><button class="ch">design</button><button class="ch">development</button><button class="ch">marketing</button><button class="ch">analytics</button></div></div><div class="ci"><div class="cj"><span class="bl">ID</span></div><div class="bk"><span class="bl">Name</span></div><div class="bm"><span class="bl">Category</span></div><div class="bn"><span class="bl">Status</span></div><div class="af"><span class="bl">Updated</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Alpha</span></div><span class="cm">Alpha</span></div></div><div class="bm"><span class="p">active</span></div><div class="bn"><div class="cn"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Beta</span></div><span class="cm">Beta</span></div></div><div class="bm"><span class="p">active</span></div><div class="bn"><div class="cn"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Gamma</span></div><span class="cm">Gamma</span></div></div><div class="bm"><span class="p">active</span></div><div class="bn"><div class="cn"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Delta</span></div><span class="cm">Delta</span></div></div><div class="bm"><span class="p">active</span></div><div class="bn"><div class="cn"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Epsilon</span></div><span class="cm">Epsilon</span></div></div><div class="bm"><span class="p">active</span></div><div class="bn"><div class="cn"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Alpha</span></div><span class="cm">Alpha</span></div></div><div class="bm"><span class="p">pending</span></div><div class="bn"><div class="cn"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Beta</span></div><span class="cm">Beta</span></div></div><div class="bm"><span class="p">pending</span></div><div class="bn"><div class="cn"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Gamma</span></div><span class="cm">Gamma</span></div></div><div class="bm"><span class="p">pending</span></div><div class="bn"><div class="cn"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Delta</span></div><span class="cm">Delta</span></div></div><div class="bm"><span class="p">pending</span></div><div class="bn"><div class="cn"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Epsilon</span></div><span class="cm">Epsilon</span></div></div><div class="bm"><span class="p">pending</span></div><div class="bn"><div class="cn"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Alpha</span></div><span class="cm">Alpha</span></div></div><div class="bm"><span class="p">completed</span></div><div class="bn"><div class="cn"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Beta</span></div><span class="cm">Beta</span></div></div><div class="bm"><span class="p">completed</span></div><div class="bn"><div class="cn"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Gamma</span></div><span class="cm">Gamma</span></div></div><div class="bm"><span class="p">completed</span></div><div class="bn"><div class="cn"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Delta</span></div><span class="cm">Delta</span></div></div><div class="bm"><span class="p">completed</span></div><div class="bn"><div class="cn"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Epsilon</span></div><span class="cm">Epsilon</span></div></div><div class="bm"><span class="p">completed</span></div><div class="bn"><div class="cn"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Alpha</span></div><span class="cm">Alpha</span></div></div><div class="bm"><span class="p">archived</span></div><div class="bn"><div class="cn"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Beta</span></div><span class="cm">Beta</span></div></div><div class="bm"><span class="p">archived</span></div><div class="bn"><div class="cn"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Gamma</span></div><span class="cm">Gamma</span></div></div><div class="bm"><span class="p">archived</span></div><div class="bn"><div class="cn"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Delta</span></div><span class="cm">Delta</span></div></div><div class="bm"><span class="p">archived</span></div><div class="bn"><div class="cn"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Epsilon</span></div><span class="cm">Epsilon</span></div></div><div class="bm"><span class="p">archived</span></div><div class="bn"><div class="cn"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="co"><span class="p">Showing 1-20 of 142 results</span><div class="af"></div><div class="cp"><button class="cq">Prev</button><button class="cr">1</button><button class="cq">2</button><button class="cq">3</button><button class="cq">Next</button></div></div></div></div></div></div></div><footer class="cs"><div class="ct"><div class="cu"><span class="cv">htmlang Dashboard</span><span class="g">Built with htmlang - a declarative HTML language</span><span class="w">Copyright 2025 htmlang contributors</span></div><div class="m"><span class="cw">Product</span><a href="/features" class="cx">Features</a><a href="/pricing" class="cx">Pricing</a><a href="/changelog" class="cx">Changelog</a><a href="/roadmap" class="cx">Roadmap</a></div><div class="m"><span class="cw">Resources</span><a href="/docs" class="cx">Documentation</a><a href="/api" class="cx">API Reference</a><a href="/guides" class="cx">Guides</a><a href="/examples" class="cx">Examples</a></div><div class="m"><span class="cw">Company</span><a href="/about" class="cx">About</a><a href="/blog" class="cx">Blog</a><a href="/careers" class="cx">Careers</a><a href="/contact" class="cx">Contact</a></div><div class="m"><span class="cw">Legal</span><a href="/privacy" class="cx">Privacy Policy</a><a href="/terms" class="cx">Terms of Service</a><a href="/cookies" class="cx">Cookie Policy</a></div></div></footer></div></div></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Stress Test Large Document</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}:root{--primary:#3b82f6;--secondary:#10b981;--accent:#f59e0b;--danger:#ef4444;--dark:#1e293b;--light:#f8fafc;--gray:#64748b;--border:#e2e8f0;--radius:8;--gap:16;--gap-sm:8;--gap-lg:24;}@layer htmlang{.a{display:flex;flex-direction:row;gap:0;min-height:100vh;}.b{display:flex;flex-direction:column;width:260px;background:white;border-right:1px solid var(--border);padding:16px;gap:8px;}.c{display:flex;flex-direction:row;gap:12px;padding:8px;align-items:center;}.d{flex-direction:column;width:36px;height:36px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;}.e{display:flex;flex-direction:column;gap:2px;}.f{font-weight:bold;font-size:16px;color:var(--dark);}.g{font-size:12px;color:var(--gray);}.h{display:flex;flex-direction:column;width:100%;height:1px;background:var(--border);margin:8px 0;}.i{display:flex;flex-direction:column;margin-bottom:24px;}.j{font-weight:bold;font-size:12px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;}.k{color:var(--primary);font-weight:bold;padding:8px 12px;background:#eff6ff;border-radius:6px;}.l{color:var(--gray);padding:8px 12px;border-radius:6px;transition:all 0.15s;}.m{display:flex;flex-direction:column;gap:4px;}.n{display:flex;flex-direction:row;gap:8px;align-items:center;}.o{display:flex;flex-direction:column;width:6px;height:6px;border-radius:50%;background:var(--secondary);}.p{font-size:13px;color:var(--gray);}.q{display:flex;flex-direction:column;width:6px;height:6px;border-radius:50%;background:var(--accent);}.r{display:flex;flex-direction:column;width:6px;height:6px;border-radius:50%;background:#cbd5e1;}.s{display:flex;flex-direction:column;margin-top:auto;padding:12px;background:#f1f5f9;border-radius:8px;}.t{flex-direction:column;width:28px;height:28px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;}.u{display:flex;flex-direction:column;gap:0;}.v{font-size:13px;font-weight:bold;color:var(--dark);}.w{font-size:11px;color:var(--gray);}.x{display:flex;flex-direction:column;flex:1;min-width:0;background:var(--light);}.y{display:flex;flex-direction:column;padding:12px 24px;background:white;border-bottom:1px solid var(--border);}.z{display:flex;flex-direction:row;gap:16px;align-items:center;}.aa{display:flex;flex-direction:row;gap:8px;}.ab{color:var(--gray);font-size:14px;}.ac{color:var(--border);font-size:14px;}.ad{color:var(--dark);font-size:14px;}.ae{font-weight:bold;font-size:22px;color:var(--dark);}.af{display:flex;flex-direction:column;flex:1;min-width:0;}.ag{display:flex;flex-direction:row;gap:12px;align-items:center;}.ah{padding:8px 12px;border-radius:6px;border:1px solid var(--border);width:240px;transition:all 0.2s;}.ai{display:flex;flex-direction:column;position:relative;}.aj{padding:8px 16px;background:var(--primary);color:white;border-radius:6px;border:none solid currentColor;cursor:pointer;transition:all 0.2s;font-size:14px;}.ak{display:flex;flex-direction:column;position:absolute;top:-2px;right:-2px;}.al{display:flex;flex-direction:column;width:8px;height:8px;border-radius:50%;background:var(--danger);}.am{padding:8px 16px;background:var(--gray);color:white;border-radius:6px;border:none solid currentColor;cursor:pointer;transition:all 0.2s;font-size:14px;}.an{display:flex;flex-direction:column;padding:24px;overflow-y:auto;}.ao{display:flex;flex-direction:column;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:12px 16px;}.ap{color:#1e40af;font-size:14px;}.aq,.av{display:flex;flex-direction:row;gap:16px;margin-top:24px;}.ar{display:flex;flex-direction:column;background:white;border-radius:8px;padding:16px;border:1px solid var(--border);}.as{font-size:14px;color:var(--gray);}.at{font-weight:bold;font-size:28px;color:var(--dark);margin-top:4px;}.au{font-size:12px;color:var(--secondary);margin-top:2px;}.aw{display:flex;flex-direction:column;background:white;border-radius:8px;padding:16px;border:1px solid var(--border);transition:all 0.2s;animation:fade-in 0.3s ease;}.ax{font-weight:bold;font-size:18px;color:var(--dark);}.ay{display:flex;flex-direction:column;margin-top:8px;}.az{flex-direction:column;height:200px;background:var(--light);border-radius:6px;margin-top:12px;display:flex;align-items:flex-end;padding:16px;gap:8px;}.a0{display:flex;flex-direction:column;width:100%;height:40%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a1{display:flex;flex-direction:column;width:100%;height:65%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a2{display:flex;flex-direction:column;width:100%;height:45%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a3{display:flex;flex-direction:column;width:100%;height:80%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a4{display:flex;flex-direction:column;width:100%;height:55%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a5{display:flex;flex-direction:column;width:100%;height:70%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a6{display:flex;flex-direction:column;width:100%;height:90%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a7{display:flex;flex-direction:column;width:100%;height:60%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a8{display:flex;flex-direction:column;width:100%;height:75%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.a9{display:flex;flex-direction:column;width:100%;height:85%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.ba{display:flex;flex-direction:column;width:100%;height:50%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.bb{display:flex;flex-direction:column;width:100%;height:95%;background:var(--primary);border-radius:4 4 0 0;transition:all 0.2s;min-width:20px;}.bc{display:flex;flex-direction:column;gap:12px;margin-top:12px;}.bd{display:flex;flex-direction:row;gap:12px;align-items:center;padding:8px;border-radius:6px;}.be{flex-direction:column;width:32px;height:32px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;}.bf{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0;}.bg{font-size:14px;font-weight:bold;color:var(--dark);}.bh{display:flex;flex-direction:column;flex:1;min-width:0;gap:16px;}.bi{display:flex;flex-direction:column;gap:0;margin-top:12px;}.bj{display:flex;flex-direction:row;gap:0;padding:8px 16px;background:var(--light);border-bottom:1px solid var(--border);}.bk{display:flex;flex-direction:column;width:200px;}.bl{font-weight:bold;font-size:12px;color:var(--gray);text-transform:uppercase;}.bm{display:flex;flex-direction:column;width:150px;}.bn{display:flex;flex-direction:column;width:120px;}.bo{display:flex;flex-direction:row;gap:0;padding:12px 16px;border-bottom:1px solid var(--border);}.bp{font-size:14px;}.bq{display:flex;flex-direction:row;gap:0;}.br{font-size:13px;color:var(--dark);flex:1;min-width:0;}.bs{display:flex;flex-direction:column;width:100%;height:8px;background:var(--border);border-radius:4px;overflow:hidden;}.bt{display:flex;flex-direction:column;width:67%;height:100%;background:var(--primary);border-radius:4px;}.bu{display:flex;flex-direction:column;gap:8px;margin-top:12px;}.bv{display:flex;flex-direction:row;gap:12px;padding:8px 12px;align-items:center;}.bw{flex-direction:column;width:24px;height:24px;border-radius:50%;background:#eff6ff;display:flex;align-items:center;justify-content:center;}.bx{font-size:10px;color:var(--primary);font-weight:bold;}.by{font-size:14px;color:var(--dark);flex:1;min-width:0;}.bz{display:flex;flex-direction:column;width:340px;gap:16px;}.b0{padding:8px 16px;background:var(--secondary);color:white;border-radius:6px;border:none solid currentColor;cursor:pointer;transition:all 0.2s;font-size:14px;}.b1{padding:8px 16px;background:var(--accent);color:white;border-radius:6px;border:none solid currentColor;cursor:pointer;transition:all 0.2s;font-size:14px;}.b2{padding:8px 16px;background:var(--gray);color:white;border-radius:6px;border:none solid currentColor;cursor:pointer;transition:all 0.2s;font-size:14px;}.b3{display:flex;flex-direction:row;gap:10px;align-items:center;padding:6px 8px;border-radius:6px;transition:all 0.15s;}.b4{display:flex;flex-direction:column;gap:0;flex:1;min-width:0;}.b5{display:flex;flex-direction:column;width:8px;height:8px;border-radius:50%;background:var(--secondary);}.b6{display:flex;flex-direction:row;gap:0;flex-wrap:wrap;margin-top:8px;}.b7{flex-direction:column;display:inline-block;padding:4px 10px;background:#f1f5f9;color:var(--gray);border-radius:4px;font-size:12px;margin:2px;}.b8{display:flex;flex-direction:row;gap:10px;padding:10px 12px;border-bottom:1px solid var(--border);align-items:center;}.b9{display:flex;flex-direction:row;gap:0;align-items:center;}.ca{display:flex;flex-direction:column;width:40px;height:22px;border-radius:11px;background:var(--primary);padding:2px;cursor:pointer;transition:all 0.2s;}.cb{display:flex;flex-direction:column;width:18px;height:18px;border-radius:50%;background:white;margin-left:18px;transition:all 0.2s;}.cc{display:flex;flex-direction:column;width:40px;height:22px;border-radius:11px;background:#cbd5e1;padding:2px;cursor:pointer;transition:all 0.2s;}.cd{display:flex;flex-direction:column;width:18px;height:18px;border-radius:50%;background:white;transition:all 0.2s;}.ce{display:flex;flex-direction:column;margin-top:24px;}.cf{display:flex;flex-direction:row;gap:16px;margin-bottom:12px;align-items:center;}.cg{padding:8px 12px;border-radius:6px;border:1px solid var(--border);width:240px;}.ch{padding:6px 12px;border-radius:4px;border:1px solid var(--border);background:white;color:var(--gray);font-size:13px;cursor:pointer;transition:all 0.15s;}.ci{display:flex;flex-direction:row;gap:0;padding:8px 16px;background:var(--light);border-bottom:2px solid var(--border);border-radius:6 6 0 0;}.cj{display:flex;flex-direction:column;width:60px;}.ck{display:flex;flex-direction:row;gap:0;padding:10px 16px;border-bottom:1px solid var(--border);cursor:pointer;transition:all 0.1s;}.cl{flex-direction:column;width:24px;height:24px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;}.cm{font-size:14px;color:var(--dark);}.cn{flex-direction:column;display:inline-block;padding:2px 8px;background:var(--secondary);color:white;border-radius:12px;font-size:12px;}.co{display:flex;flex-direction:row;gap:16px;padding:12px 16px;align-items:center;border-top:1px solid var(--border);}.cp{display:flex;flex-direction:row;gap:4px;}.cq{padding:6px 10px;border-radius:4px;border:1px solid var(--border);background:white;color:var(--gray);font-size:13px;cursor:pointer;}.cr{padding:6px 10px;border-radius:4px;border:1px solid var(--primary);background:var(--primary);color:white;font-size:13px;cursor:pointer;}.cs{display:flex;flex-direction:column;padding:24px 24px;background:white;border-top:1px solid var(--border);}.ct{display:flex;flex-direction:row;gap:24px;}.cu{display:flex;flex-direction:column;gap:8px;flex:1;min-width:0;}.cv{font-weight:bold;font-size:14px;color:var(--dark);}.cw{font-weight:bold;font-size:12px;color:var(--gray);text-transform:uppercase;margin-bottom:4px;}.cx{color:var(--gray);font-size:13px;}.l:hover{color:var(--primary);background:#f1f5f9;}.ab:hover,.cx:hover{color:var(--primary);}.aj:hover,.am:hover,.b0:hover,.b1:hover,.b2:hover{background:var(--dark);}.aw:hover{box-shadow:0 4 12 rgba(0,0,0,0.1);}.a0:hover,.a1:hover,.a2:hover,.a3:hover,.a4:hover,.a5:hover,.a6:hover,.a7:hover,.a8:hover,.a9:hover,.ba:hover,.bb:hover{background:#2563eb;}.bd:hover,.bo:hover,.ck:hover{background:var(--light);}.b3:hover{background:#f1f5f9;}.ch:hover{background:#f1f5f9;color:var(--dark);}.ah:focus{border:1px solid var(--primary);}@media(min-width:640px){.aq,.ct{flex-direction:column;}.bz{flex:1;min-width:0;}}@media(min-width:1024px){.aq,.ct{flex-direction:row;}}@media(prefers-color-scheme:dark){.b{background:#0f172a;border-right:1px solid var(--dark);}.f,.v,.ad,.ae,.at,.ax,.bg,.br,.by,.cm,.cv{color:white;}.h,.bs{background:#334155;}.k,.bw{background:#1e3a5f;}.s,.ch,.cq{background:var(--dark);}.x,.az,.bj,.ci{background:#0f172a;}.y{background:#0f172a;border-bottom:1px solid var(--dark);}.ah{border:1px solid #334155;background:var(--dark);color:white;}.ar,.aw{background:var(--dark);border:1px solid #334155;}.bo,.b8,.ck{border-bottom:1px solid #334155;}.b7{background:#334155;color:#94a3b8;}.cc{background:#475569;}.cs{background:#0f172a;border-top:1px solid var(--dark);}}}@keyframes fade-in {from{opacity:0}to{opacity:1}}@keyframes slide-down {0%{transform:translateY(-10px);opacity:0}100%{transform:translateY(0);opacity:1}}</style></head><body><div class="a"><div class="b"><div class="c"><div class="d"><span>HL</span></div><div class="e"><span class="f">htmlang</span><span class="g">Dashboard v2.0</span></div></div><div class="h"></div><div class="i"><span class="j">Navigation</span><div class="e"><a href="/dashboard" class="k">Dashboard</a><a href="/analytics" class="l">Analytics</a><a href="/projects" class="l">Projects</a><a href="/team" class="l">Team</a><a href="/messages" class="l">Messages</a><a href="/calendar" class="l">Calendar</a><a href="/documents" class="l">Documents</a><a href="/settings" class="l">Settings</a></div></div><div class="i"><span class="j">Favorites</span><div class="e"><a href="/project/alpha" class="l">Project Alpha</a><a href="/project/beta" class="l">Project Beta</a><a href="/reports/q4" class="l">Q4 Reports</a></div></div><div class="i"><span class="j">Shortcuts</span><div class="m"><div class="n"><div class="o"></div><span class="p">Online (12)</span></div><div class="n"><div class="q"></div><span class="p">Away (3)</span></div><div class="n"><div class="r"></div><span class="p">Offline (5)</span></div></div></div><div class="s"><div class="n"><div class="t"><span>JD</span></div><div class="u"><span class="v">Jane Doe</span><span class="w">jane@example.com</span></div></div></div></div><div class="x"><header class="y"><div class="z"><div class="e"><div class="aa"><div class="aa"><a href="/" class="ab">Home</a><span class="ac">/</span></div><span class="ad">Dashboard</span></div><span class="ae">Dashboard Overview</span></div><div class="af"></div><div class="ag"><input class="ah" type="search" placeholder="Search..."><div class="ai"><button class="aj">Alerts</button><div class="ak"><div class="al"></div></div></div><button class="am">Settings</button></div></div></header><div class="an"><div class="ao"><span class="ap">Welcome back! You have 3 new notifications and 2 pending tasks.</span></div><div class="aq"><div class="ar"><span class="as">Total Revenue</span><span class="at">$48</span><span class="au">+12.5% from last month</span></div><div class="ar"><span class="as">Active Users</span><span class="at">2</span><span class="au">+8.2% from last week</span></div><div class="ar"><span class="as">Conversion Rate</span><span class="at">3.24%</span><span class="au">+0.4% from yesterday</span></div><div class="ar"><span class="as">Open Tickets</span><span class="at">142</span><span class="au">-5.1% from last month</span></div></div><div class="av"><div class="aw"><span class="ax">Revenue Overview</span><div class="ay"><div class="az"><div class="a0"></div><div class="a1"></div><div class="a2"></div><div class="a3"></div><div class="a4"></div><div class="a5"></div><div class="a6"></div><div class="a7"></div><div class="a8"></div><div class="a9"></div><div class="ba"></div><div class="bb"></div></div></div></div><div class="aw"><span class="ax">Activity Feed</span><div class="ay"><div class="bc"><div class="bd"><div class="be"><span>Alice</span></div><div class="bf"><span class="bg">Alice</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Bob</span></div><div class="bf"><span class="bg">Bob</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Carol</span></div><div class="bf"><span class="bg">Carol</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Dave</span></div><div class="bf"><span class="bg">Dave</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Eve</span></div><div class="bf"><span class="bg">Eve</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Frank</span></div><div class="bf"><span class="bg">Frank</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Grace</span></div><div class="bf"><span class="bg">Grace</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div><div class="bd"><div class="be"><span>Hank</span></div><div class="bf"><span class="bg">Hank</span><span class="g">Updated project files</span></div><span class="w">2h ago</span></div></div></div></div></div><div class="aq"><div class="bh"><div class="aw"><span class="ax">Project Status</span><div class="ay"><div class="bi"><div class="bj"><div class="bk"><span class="bl">Project</span></div><div class="bm"><span class="bl">Status</span></div><div class="bn"><span class="bl">Progress</span></div><div class="af"><span class="bl">Due Date</span></div></div><div class="bo"><div class="bk"><span class="bp">Project Alpha</span></div><div class="bm"><span class="as">In Progress</span></div><div class="bn"><span class="bp">75%</span></div><div class="af"><span class="as">Dec 15 2025</span></div></div><div class="bo"><div class="bk"><span class="bp">Project Beta</span></div><div class="bm"><span class="as">Review</span></div><div class="bn"><span class="bp">90%</span></div><div class="af"><span class="as">Nov 30 2025</span></div></div><div class="bo"><div class="bk"><span class="bp">Project Gamma</span></div><div class="bm"><span class="as">Planning</span></div><div class="bn"><span class="bp">20%</span></div><div class="af"><span class="as">Jan 20 2026</span></div></div><div class="bo"><div class="bk"><span class="bp">Project Delta</span></div><div class="bm"><span class="as">Completed</span></div><div class="bn"><span class="bp">100%</span></div><div class="af"><span class="as">Oct 10 2025</span></div></div><div class="bo"><div class="bk"><span class="bp">Project Epsilon</span></div><div class="bm"><span class="as">On Hold</span></div><div class="bn"><span class="bp">45%</span></div><div class="af"><span class="as">Feb 28 2026</span></div></div></div></div></div><div class="aw"><span class="ax">Progress Breakdown</span><div class="ay"><div class="bc"><div class="m"><div class="bq"><span class="br">Alpha</span><span class="p">67%</span></div><div class="bs"><div class="bt"></div></div></div><div class="m"><div class="bq"><span class="br">Beta</span><span class="p">67%</span></div><div class="bs"><div class="bt"></div></div></div><div class="m"><div class="bq"><span class="br">Gamma</span><span class="p">67%</span></div><div class="bs"><div class="bt"></div></div></div><div class="m"><div class="bq"><span class="br">Delta</span><span class="p">67%</span></div><div class="bs"><div class="bt"></div></div></div><div class="m"><div class="bq"><span class="br">Epsilon</span><span class="p">67%</span></div><div class="bs"><div class="bt"></div></div></div></div></div></div><div class="aw"><span class="ax">Recent Activity Log</span><div class="ay"><div class="bu"><div class="bv"><div class="bw"><span class="bx">0</span></div><span class="by">Deployed v2.3.1 to production</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">1</span></div><span class="by">Merged feature/auth-redesign</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">2</span></div><span class="by">Closed issue #482: Login timeout</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">3</span></div><span class="by">Updated CI/CD pipeline config</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">4</span></div><span class="by">Added unit tests for payment module</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">5</span></div><span class="by">Reviewed PR #156: API refactor</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">6</span></div><span class="by">Fixed memory leak in worker pool</span><span class="w">just now</span></div><div class="bv"><div class="bw"><span class="bx">7</span></div><span class="by">Released hotfix 2.3.2</span><span class="w">just now</span></div></div></div></div></div><div class="bz"><div class="aw"><span class="ax">Quick Actions</span><div class="ay"><div class="bu"><button class="aj">New Project</button><button class="b0">Create Report</button><button class="b1">Invite Member</button><button class="b2">View Backlog</button></div></div></div><div class="aw"><span class="ax">Team Members</span><div class="ay"><div class="bu"><div class="b3"><div class="t"><span>Alice</span></div><div class="b4"><span class="v">Alice</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Bob</span></div><div class="b4"><span class="v">Bob</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Carol</span></div><div class="b4"><span class="v">Carol</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Dave</span></div><div class="b4"><span class="v">Dave</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Eve</span></div><div class="b4"><span class="v">Eve</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Frank</span></div><div class="b4"><span class="v">Frank</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Grace</span></div><div class="b4"><span class="v">Grace</span><span class="w">Developer</span></div><div class="b5"></div></div><div class="b3"><div class="t"><span>Hank</span></div><div class="b4"><span class="v">Hank</span><span class="w">Developer</span></div><div class="b5"></div></div></div></div></div><div class="aw"><span class="ax">Tags</span><div class="ay"><div class="b6"><div class="b7"><span>frontend</span></div><div class="b7"><span>backend</span></div><div class="b7"><span>devops</span></div><div class="b7"><span>design</span></div><div class="b7"><span>mobile</span></div><div class="b7"><span>security</span></div><div class="b7"><span>performance</span></div><div class="b7"><span>testing</span></div><div class="b7"><span>docs</span></div><div class="b7"><span>infra</span></div><div class="b7"><span>ux</span></div><div class="b7"><span>api</span></div></div></div></div><div class="aw"><span class="ax">Notifications</span><div class="ay"><div class="bi"><div class="b8"><div class="al"></div><span class="br">New deployment completed</span><span class="w">now</span></div><div class="b8"><div class="al"></div><span class="br">PR review requested</span><span class="w">now</span></div><div class="b8"><div class="al"></div><span class="br">Build failed on staging</span><span class="w">now</span></div><div class="b8"><div class="al"></div><span class="br">Meeting in 30 minutes</span><span class="w">now</span></div><div class="b8"><div class="al"></div><span class="br">Subscription renewal due</span><span class="w">now</span></div></div></div></div><div class="aw"><span class="ax">Settings</span><div class="ay"><div class="bc"><div class="b9"><span class="by">Dark Mode</span><div class="ca"><div class="cb"></div></div></div><div class="b9"><span class="by">Notifications</span><div class="ca"><div class="cb"></div></div></div><div class="b9"><span class="by">Auto-save</span><div class="cc"><div class="cd"></div></div></div><div class="b9"><span class="by">Compact View</span><div class="cc"><div class="cd"></div></div></div></div></div></div></div></div><div class="ce"><div class="aw"><span class="ax">Data Table</span><div class="ay"><div class="bi"><div class="cf"><input class="cg" type="search" placeholder="Filter results..."><div class="af"></div><div class="aa"><button class="ch">all</button><button class="ch">design</button><button class="ch">development</button><button class="ch">marketing</button><button class="ch">analytics</button></div></div><div class="ci"><div class="cj"><span class="bl">ID</span></div><div class="bk"><span class="bl">Name</span></div><div class="bm"><span class="bl">Category</span></div><div class="bn"><span class="bl">Status</span></div><div class="af"><span class="bl">Updated</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Alpha</span></div><span class="cm">Alpha</span></div></div><div class="bm"><span class="p">active</span></div><div class="bn"><div class="cn"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Beta</span></div><span class="cm">Beta</span></div></div><div class="bm"><span class="p">active</span></div><div class="bn"><div class="cn"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Gamma</span></div><span class="cm">Gamma</span></div></div><div class="bm"><span class="p">active</span></div><div class="bn"><div class="cn"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Delta</span></div><span class="cm">Delta</span></div></div><div class="bm"><span class="p">active</span></div><div class="bn"><div class="cn"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Epsilon</span></div><span class="cm">Epsilon</span></div></div><div class="bm"><span class="p">active</span></div><div class="bn"><div class="cn"><span>active</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Alpha</span></div><span class="cm">Alpha</span></div></div><div class="bm"><span class="p">pending</span></div><div class="bn"><div class="cn"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Beta</span></div><span class="cm">Beta</span></div></div><div class="bm"><span class="p">pending</span></div><div class="bn"><div class="cn"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Gamma</span></div><span class="cm">Gamma</span></div></div><div class="bm"><span class="p">pending</span></div><div class="bn"><div class="cn"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Delta</span></div><span class="cm">Delta</span></div></div><div class="bm"><span class="p">pending</span></div><div class="bn"><div class="cn"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Epsilon</span></div><span class="cm">Epsilon</span></div></div><div class="bm"><span class="p">pending</span></div><div class="bn"><div class="cn"><span>pending</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Alpha</span></div><span class="cm">Alpha</span></div></div><div class="bm"><span class="p">completed</span></div><div class="bn"><div class="cn"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Beta</span></div><span class="cm">Beta</span></div></div><div class="bm"><span class="p">completed</span></div><div class="bn"><div class="cn"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Gamma</span></div><span class="cm">Gamma</span></div></div><div class="bm"><span class="p">completed</span></div><div class="bn"><div class="cn"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Delta</span></div><span class="cm">Delta</span></div></div><div class="bm"><span class="p">completed</span></div><div class="bn"><div class="cn"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Epsilon</span></div><span class="cm">Epsilon</span></div></div><div class="bm"><span class="p">completed</span></div><div class="bn"><div class="cn"><span>completed</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Alpha</span></div><span class="cm">Alpha</span></div></div><div class="bm"><span class="p">archived</span></div><div class="bn"><div class="cn"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Beta</span></div><span class="cm">Beta</span></div></div><div class="bm"><span class="p">archived</span></div><div class="bn"><div class="cn"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Gamma</span></div><span class="cm">Gamma</span></div></div><div class="bm"><span class="p">archived</span></div><div class="bn"><div class="cn"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Delta</span></div><span class="cm">Delta</span></div></div><div class="bm"><span class="p">archived</span></div><div class="bn"><div class="cn"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="ck"><div class="cj"><span class="p">#42</span></div><div class="bk"><div class="n"><div class="cl"><span>Epsilon</span></div><span class="cm">Epsilon</span></div></div><div class="bm"><span class="p">archived</span></div><div class="bn"><div class="cn"><span>archived</span></div></div><div class="af"><span class="p">2 hours ago</span></div></div><div class="co"><span class="p">Showing 1-20 of 142 results</span><div class="af"></div><div class="cp"><button class="cq">Prev</button><button class="cr">1</button><button class="cq">2</button><button class="cq">3</button><button class="cq">Next</button></div></div></div></div></div></div></div><footer class="cs"><div class="ct"><div class="cu"><span class="cv">htmlang Dashboard</span><span class="g">Built with htmlang - a declarative HTML language</span><span class="w">Copyright 2025 htmlang contributors</span></div><div class="m"><span class="cw">Product</span><a href="/features" class="cx">Features</a><a href="/pricing" class="cx">Pricing</a><a href="/changelog" class="cx">Changelog</a><a href="/roadmap" class="cx">Roadmap</a></div><div class="m"><span class="cw">Resources</span><a href="/docs" class="cx">Documentation</a><a href="/api" class="cx">API Reference</a><a href="/guides" class="cx">Guides</a><a href="/examples" class="cx">Examples</a></div><div class="m"><span class="cw">Company</span><a href="/about" class="cx">About</a><a href="/blog" class="cx">Blog</a><a href="/careers" class="cx">Careers</a><a href="/contact" class="cx">Contact</a></div><div class="m"><span class="cw">Legal</span><a href="/privacy" class="cx">Privacy Policy</a><a href="/terms" class="cx">Terms of Service</a><a href="/cookies" class="cx">Cookie Policy</a></div></div></footer></div></div></body></html>