about summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/css/box.nim71
-rw-r--r--src/css/csstree.nim280
-rw-r--r--src/css/cssvalues.nim18
-rw-r--r--src/css/layout.nim409
-rw-r--r--src/css/render.nim75
-rw-r--r--src/html/chadombuilder.nim1
-rw-r--r--src/html/dom.nim46
-rw-r--r--src/server/buffer.nim14
-rw-r--r--src/types/refstring.nim26
9 files changed, 431 insertions, 509 deletions
diff --git a/src/css/box.nim b/src/css/box.nim
index dca3ade2..56943f63 100644
--- a/src/css/box.nim
+++ b/src/css/box.nim
@@ -2,6 +2,7 @@ import css/cssvalues
 import css/lunit
 import html/dom
 import types/bitmap
+import types/refstring
 
 type
   DimensionType* = enum
@@ -15,10 +16,6 @@ type
     offset*: Offset
     size*: Size
 
-  InlineImage* = ref object
-    state*: InlineImageState
-    bmp*: NetworkBitmap
-
   TextRun* = ref object
     offset*: Offset
     str*: string
@@ -44,10 +41,6 @@ type
   InlineBoxState* = object
     startOffset*: Offset # offset of the first word, for position: absolute
     areas*: seq[Area] # background that should be painted by box
-    runs: seq[TextRun]
-
-  InlineBoxType* = enum
-    ibtParent, ibtText, ibtNewline, ibtBitmap, ibtBox
 
   Span* = object
     start*: LUnit
@@ -88,48 +81,27 @@ type
     render*: BoxRenderState # render output
     computed*: CSSValues
     element*: Element
+    children*: seq[CSSBox]
 
   BlockBox* = ref object of CSSBox
     sizes*: ResolvedSizes # tree builder output -> layout input
     state*: BoxLayoutState # layout output -> render input
-    children*: seq[CSSBox]
 
   InlineBox* = ref object of CSSBox
     state*: InlineBoxState
-    case t*: InlineBoxType
-    of ibtParent:
-      children*: seq[CSSBox]
-    of ibtText:
-      text*: CharacterData # note: this has no parent.
-    of ibtNewline:
-      discard
-    of ibtBitmap:
-      image*: InlineImage
-    of ibtBox:
-      box*: BlockBox
-
-iterator children*(box: CSSBox): lent CSSBox {.inline.} =
-  if box of BlockBox:
-    let box = BlockBox(box)
-    for child in box.children:
-      yield child
-  else:
-    let ibox = InlineBox(box)
-    case ibox.t
-    of ibtParent:
-      for child in ibox.children:
-        yield child
-    of ibtBox:
-      yield CSSBox(ibox.box)
-    else:
-      discard
 
-# We store runs in state as a private field, so that we can both check
-# if the box type is correct and reset them on relayout by zeroing out
-# state.
-template runs*(ibox: InlineBox): seq[TextRun] =
-  assert ibox.t == ibtText
-  ibox.state.runs
+  InlineTextBox* = ref object of InlineBox
+    runs*: seq[TextRun] # state
+    text*: RefString
+
+  InlineNewLineBox* = ref object of InlineBox
+
+  InlineImageBox* = ref object of InlineBox
+    imgstate*: InlineImageState
+    bmp*: NetworkBitmap
+
+  InlineBlockBox* = ref object of InlineBox
+    box*: BlockBox
 
 func offset*(x, y: LUnit): Offset =
   return [dtHorizontal: x, dtVertical: y]
@@ -205,3 +177,18 @@ func topLeft*(s: RelativeRect): Offset =
 proc `+=`*(span: var Span; u: LUnit) =
   span.start += u
   span.send += u
+
+when defined(debug):
+  proc computedTree*(box: CSSBox): string =
+    result = "<"
+    if box.computed{"display"} != DisplayInline:
+      result &= "div"
+    else:
+      result &= "span"
+    let computed = box.computed.copyProperties()
+    if computed{"display"} == DisplayBlock:
+      computed{"display"} = DisplayInline
+    result &= " style='" & $computed.serializeEmpty() & "'>\n"
+    for it in box.children:
+      result &= it.computedTree()
+    result &= "\n</div>"
diff --git a/src/css/csstree.nim b/src/css/csstree.nim
index d4702cea..95afc237 100644
--- a/src/css/csstree.nim
+++ b/src/css/csstree.nim
@@ -1,5 +1,12 @@
 # Tree building.
 #
+#TODO: this is currently a separate pass from layout, meaning at least
+# two tree traversals are required.  Ideally, these should be collapsed
+# into a single pass, reusing parts of previous layout passes when
+# possible.
+#
+# ---
+#
 # This wouldn't be nearly as complex as it is if not for CSS's asinine
 # anonymous table box generation rules.  In particular:
 # * Runs of misparented boxes inside a table/table row/table row group
@@ -19,6 +26,7 @@
 # Whatever your reason may be for looking at this: good luck.
 
 import chame/tags
+import css/box
 import css/cascade
 import css/cssvalues
 import css/selectorparser
@@ -26,32 +34,34 @@ import html/catom
 import html/dom
 import types/bitmap
 import types/color
+import types/refstring
 import utils/twtstr
 
 type
-  StyledType* = enum
+  StyledNodeType = enum
     stElement, stText, stImage, stBr
 
   # Abstraction over the DOM to pretend that elements, text, replaced
   # and pseudo-elements are derived from the same type.
-  StyledNode* = object
-    element*: Element
-    computed*: CSSValues
-    pseudo*: PseudoElement
+  StyledNode = object
+    element: Element
+    computed: CSSValues
+    pseudo: PseudoElement
     skipChildren: bool
-    case t*: StyledType
+    case t: StyledNodeType
     of stText:
-      text*: CharacterData
+      text: RefString
     of stElement:
       anonChildren: seq[StyledNode]
     of stImage:
-      bmp*: NetworkBitmap
+      bmp: NetworkBitmap
     of stBr: # <br> element
       discard
 
-  TreeContext* = object
+  TreeContext = object
     quoteLevel: int
     listItemCounter: int
+    rootProperties: CSSValues
 
   TreeFrame = object
     parent: Element
@@ -64,6 +74,9 @@ type
     anonInlineComputed: CSSValues
     pctx: ptr TreeContext
 
+# Forward declarations
+proc build(ctx: var TreeContext; cached: CSSBox; styledNode: StyledNode): CSSBox
+
 template ctx(frame: TreeFrame): var TreeContext =
   frame.pctx[]
 
@@ -71,7 +84,7 @@ when defined(debug):
   func `$`*(node: StyledNode): string =
     case node.t
     of stText:
-      return node.text.data
+      return node.text
     of stElement:
       if node.pseudo != peNone:
         return $node.element.tagType & "::" & $node.pseudo
@@ -81,16 +94,6 @@ when defined(debug):
     of stBr:
       return "#br"
 
-# Root
-proc initStyledElement*(element: Element): StyledNode =
-  if element.computed == nil:
-    element.applyStyle()
-  result = StyledNode(
-    t: stElement,
-    element: element,
-    computed: element.computed
-  )
-
 func inheritFor(frame: TreeFrame; display: CSSDisplay): CSSValues =
   result = frame.computed.inheritProperties()
   result{"display"} = display
@@ -107,13 +110,13 @@ proc getAnonInlineComputed(frame: var TreeFrame): CSSValues =
       frame.anonInlineComputed = frame.computed.inheritProperties()
   return frame.anonInlineComputed
 
-proc displayed(frame: TreeFrame; text: CharacterData): bool =
-  if text.data.len == 0:
+proc displayed(frame: TreeFrame; text: RefString): bool =
+  if text.len == 0:
     return false
   return frame.computed{"display"} == DisplayInline or
     frame.lastChildWasInline or
     frame.computed{"white-space"} in WhiteSpacePreserve or
-    not text.data.onlyWhitespace()
+    not text.onlyWhitespace()
 
 #TODO implement table columns
 const DisplayNoneLike = {
@@ -128,18 +131,23 @@ proc displayed(frame: TreeFrame; pseudo: PseudoElement): bool =
 proc displayed(frame: TreeFrame; element: Element): bool =
   return element.computed{"display"} notin DisplayNoneLike
 
+proc initStyledAnon(element: Element; computed: CSSValues;
+    children: sink seq[StyledNode] = @[]): StyledNode =
+  result = StyledNode(
+    t: stElement,
+    element: element,
+    anonChildren: children,
+    computed: computed,
+    skipChildren: true
+  )
+
 proc getInternalTableParent(frame: var TreeFrame; display: CSSDisplay):
     var seq[StyledNode] =
   if frame.anonTableDisplay != display:
     if frame.anonComputed == nil:
       frame.anonComputed = frame.inheritFor(display)
     frame.anonTableDisplay = display
-    frame.children.add(StyledNode(
-      t: stElement,
-      element: frame.parent,
-      computed: frame.anonComputed,
-      skipChildren: true
-    ))
+    frame.children.add(initStyledAnon(frame.parent, frame.anonComputed))
   return frame.children[^1].anonChildren
 
 # Add an anonymous table to children, and return based on display either
@@ -156,16 +164,10 @@ proc addAnonTable(frame: var TreeFrame; parentDisplay, display: CSSDisplay):
       DisplayTable
     let (outer, inner) = frame.inheritFor(anonDisplay).splitTable()
     frame.anonComputed = outer
-    frame.children.add(StyledNode(
-      t: stElement,
-      computed: outer,
-      skipChildren: true,
-      anonChildren: @[StyledNode(
-        t: stElement,
-        computed: inner,
-        skipChildren: true
-      )]
-    ))
+    frame.children.add(initStyledAnon(frame.parent, outer, @[initStyledAnon(
+      frame.parent,
+      inner
+    )]))
   if display == DisplayTableCaption:
     frame.anonComputed = frame.children[^1].computed
     return frame.children[^1].anonChildren
@@ -176,11 +178,9 @@ proc addAnonTable(frame: var TreeFrame; parentDisplay, display: CSSDisplay):
   if frame.anonComputed{"display"} == DisplayTableRow:
     return frame.children[^1].anonChildren[0].anonChildren[^1].anonChildren
   frame.anonComputed = frame.inheritFor(DisplayTableRow)
-  frame.children[^1].anonChildren[0].anonChildren.add(StyledNode(
-    t: stElement,
-    element: frame.parent,
-    computed: frame.anonComputed,
-    skipChildren: true
+  frame.children[^1].anonChildren[0].anonChildren.add(initStyledAnon(
+    frame.parent,
+    frame.anonComputed
   ))
   return frame.children[^1].anonChildren[0].anonChildren[^1].anonChildren
 
@@ -192,12 +192,7 @@ proc getParent(frame: var TreeFrame; computed: CSSValues; display: CSSDisplay):
     if display in DisplayOuterInline:
       if frame.anonComputed == nil:
         frame.anonComputed = frame.inheritFor(DisplayBlock)
-      frame.children.add(StyledNode(
-        t: stElement,
-        element: frame.parent,
-        computed: frame.anonComputed,
-        skipChildren: true
-      ))
+      frame.children.add(initStyledAnon(frame.parent, frame.anonComputed))
       return frame.children[^1].anonChildren
   of DisplayTableRow:
     if display != DisplayTableCell:
@@ -230,33 +225,21 @@ proc addListItem(frame: var TreeFrame; node: sink StyledNode) =
   # Generate a marker box.
   inc frame.ctx.listItemCounter
   let computed = node.computed.inheritProperties()
-  computed{"display"} = DisplayBlock
   computed{"white-space"} = WhitespacePre
-  let t = computed{"list-style-type"}
+  let counter = frame.ctx.listItemCounter
   let markerText = StyledNode(
     t: stText,
     element: node.element,
-    text: newCharacterData(t.listMarker(frame.ctx.listItemCounter)),
-    computed: computed.inheritProperties()
+    text: newRefString(computed{"list-style-type"}.listMarker(counter)),
+    computed: computed
   )
   case node.computed{"list-style-position"}
   of ListStylePositionOutside:
-    # Generate a separate box for the content and marker.
-    node.anonChildren.add(StyledNode(
-      t: stElement,
-      element: node.element,
-      computed: computed,
-      skipChildren: true,
-      anonChildren: @[markerText]
-    ))
+    # Generate separate boxes for the content and marker.
     let computed = node.computed.inheritProperties()
     computed{"display"} = DisplayBlock
-    node.anonChildren.add(StyledNode(
-      t: stElement,
-      element: node.element,
-      computed: computed,
-      skipChildren: true
-    ))
+    node.anonChildren.add(initStyledAnon(node.element, computed, @[markerText]))
+    node.anonChildren.add(initStyledAnon(node.element, computed))
   of ListStylePositionInside:
     node.anonChildren.add(markerText)
   frame.getParent(node.computed, node.computed{"display"}).add(node)
@@ -265,12 +248,7 @@ proc addTable(frame: var TreeFrame; node: sink StyledNode) =
   var node = node
   let (outer, inner) = node.computed.splitTable()
   node.computed = outer
-  node.anonChildren.add(StyledNode(
-    t: stElement,
-    element: node.element,
-    computed: inner,
-    skipChildren: true
-  ))
+  node.anonChildren.add(initStyledAnon(node.element, inner))
   frame.getParent(node.computed, node.computed{"display"}).add(node)
 
 proc add(frame: var TreeFrame; node: sink StyledNode) =
@@ -293,15 +271,9 @@ proc add(frame: var TreeFrame; node: sink StyledNode) =
   if display == DisplayTableCaption:
     frame.captionSeen = true
 
-proc addAnon(frame: var TreeFrame; children: sink seq[StyledNode];
-    computed: CSSValues) =
-  frame.add(StyledNode(
-    t: stElement,
-    element: frame.parent,
-    anonChildren: children,
-    computed: computed,
-    skipChildren: true
-  ))
+proc addAnon(frame: var TreeFrame; computed: CSSValues;
+    children: sink seq[StyledNode]) =
+  frame.add(initStyledAnon(frame.parent, computed, children))
 
 proc addElement(frame: var TreeFrame; element: Element) =
   if element.computed == nil:
@@ -322,7 +294,7 @@ proc addPseudo(frame: var TreeFrame; pseudo: PseudoElement) =
       computed: frame.parent.computedMap[pseudo]
     ))
 
-proc addText(frame: var TreeFrame; text: CharacterData) =
+proc addText(frame: var TreeFrame; text: RefString) =
   if frame.displayed(text):
     frame.add(StyledNode(
       t: stText,
@@ -333,7 +305,7 @@ proc addText(frame: var TreeFrame; text: CharacterData) =
 
 proc addText(frame: var TreeFrame; s: sink string) =
   #TODO should probably cache these...
-  frame.addText(newCharacterData(s))
+  frame.addText(newRefString(s))
 
 proc addImage(frame: var TreeFrame; bmp: NetworkBitmap) =
   if bmp != nil and bmp.cacheId != -1:
@@ -362,19 +334,19 @@ proc addElementChildren(frame: var TreeFrame) =
       #TODO collapse subsequent text nodes into one StyledNode
       # (it isn't possible in HTML, only with JS DOM manipulation)
       let text = Text(it)
-      frame.addText(text)
+      frame.addText(text.data)
 
 proc addOptionChildren(frame: var TreeFrame; option: HTMLOptionElement) =
   if option.select != nil and option.select.attrb(satMultiple):
     frame.addText("[")
-    let cdata = newCharacterData(if option.selected: "*" else: " ")
+    let cdata = newRefString(if option.selected: "*" else: " ")
     let computed = option.computed.inheritProperties()
     computed{"color"} = cssColor(ANSIColor(1)) # red
     computed{"white-space"} = WhitespacePre
     block anon:
       var aframe = frame.ctx.initTreeFrame(option, computed)
       aframe.addText(cdata)
-      frame.addAnon(move(aframe.children), computed)
+      frame.addAnon(computed, move(aframe.children))
     frame.addText("]")
   frame.addElementChildren()
 
@@ -402,83 +374,103 @@ proc addChildren(frame: var TreeFrame) =
   else:
     frame.addElementChildren()
 
-proc addContent(frame: var TreeFrame; content: CSSContent; ctx: var TreeContext;
-    computed: CSSValues) =
+proc addContent(frame: var TreeFrame; content: CSSContent) =
   case content.t
   of ContentString:
     frame.addText(content.s)
   of ContentOpenQuote:
     let quotes = frame.computed{"quotes"}
     if quotes == nil:
-      frame.addText(quoteStart(ctx.quoteLevel))
+      frame.addText(quoteStart(frame.ctx.quoteLevel))
     elif quotes.qs.len > 0:
-      frame.addText(quotes.qs[min(ctx.quoteLevel, quotes.qs.high)].s)
+      frame.addText(quotes.qs[min(frame.ctx.quoteLevel, quotes.qs.high)].s)
     else:
       return
-    inc ctx.quoteLevel
+    inc frame.ctx.quoteLevel
   of ContentCloseQuote:
-    if ctx.quoteLevel > 0:
-      dec ctx.quoteLevel
-    let quotes = computed{"quotes"}
+    if frame.ctx.quoteLevel > 0:
+      dec frame.ctx.quoteLevel
+    let quotes = frame.computed{"quotes"}
     if quotes == nil:
-      frame.addText(quoteEnd(ctx.quoteLevel))
+      frame.addText(quoteEnd(frame.ctx.quoteLevel))
     elif quotes.qs.len > 0:
-      frame.addText(quotes.qs[min(ctx.quoteLevel, quotes.qs.high)].e)
+      frame.addText(quotes.qs[min(frame.ctx.quoteLevel, quotes.qs.high)].e)
   of ContentNoOpenQuote:
-    inc ctx.quoteLevel
+    inc frame.ctx.quoteLevel
   of ContentNoCloseQuote:
-    if ctx.quoteLevel > 0:
-      dec ctx.quoteLevel
+    if frame.ctx.quoteLevel > 0:
+      dec frame.ctx.quoteLevel
 
-proc build(frame: var TreeFrame; styledNode: StyledNode;
-    ctx: var TreeContext) =
+proc buildChildren(frame: var TreeFrame; styledNode: StyledNode) =
   for child in styledNode.anonChildren:
     frame.add(child)
-  if styledNode.skipChildren:
-    return
-  let parent = styledNode.element
-  if styledNode.pseudo == peNone:
-    frame.addPseudo(peBefore)
-    frame.addChildren()
-    frame.addPseudo(peAfter)
+  if not styledNode.skipChildren:
+    if styledNode.pseudo == peNone:
+      frame.addPseudo(peBefore)
+      frame.addChildren()
+      frame.addPseudo(peAfter)
+    else:
+      for content in frame.computed{"content"}:
+        frame.addContent(content)
+
+proc buildBox(ctx: var TreeContext; frame: TreeFrame; cached: CSSBox): CSSBox =
+  var bbox: BlockBox = nil
+  let display = frame.computed{"display"}
+  let box = if display == DisplayInline:
+    InlineBox(computed: frame.computed, element: frame.parent)
   else:
-    let computed = parent.computedMap[styledNode.pseudo].inheritProperties()
-    for content in parent.computedMap[styledNode.pseudo]{"content"}:
-      frame.addContent(content, ctx, computed)
-
-iterator children*(styledNode: StyledNode; ctx: var TreeContext): StyledNode
-    {.inline.} =
-  if styledNode.t == stElement:
+    assert display notin DisplayNoneLike
+    bbox = BlockBox(computed: frame.computed, element: frame.parent)
+    bbox
+  for child in frame.children:
+    box.children.add(ctx.build(nil, child))
+  if display in DisplayInlineBlockLike:
+    return InlineBlockBox(
+      computed: ctx.rootProperties,
+      element: frame.parent,
+      box: bbox
+    )
+  return box
+
+proc build(ctx: var TreeContext; cached: CSSBox; styledNode: StyledNode):
+    CSSBox =
+  case styledNode.t
+  of stElement:
     for reset in styledNode.computed{"counter-reset"}:
       if reset.name == "list-item":
         ctx.listItemCounter = reset.num
     let listItemCounter = ctx.listItemCounter
-    let parent = styledNode.element
-    var frame = ctx.initTreeFrame(parent, styledNode.computed)
-    frame.build(styledNode, ctx)
-    for child in frame.children:
-      yield child
+    var frame = ctx.initTreeFrame(styledNode.element, styledNode.computed)
+    frame.buildChildren(styledNode)
+    let box = ctx.buildBox(frame, cached)
     ctx.listItemCounter = listItemCounter
+    return box
+  of stText:
+    return InlineTextBox(
+      computed: styledNode.computed,
+      element: styledNode.element,
+      text: styledNode.text
+    )
+  of stBr:
+    return InlineNewLineBox(
+      computed: styledNode.computed,
+      element: styledNode.element
+    )
+  of stImage:
+    return InlineImageBox(
+      computed: styledNode.computed,
+      element: styledNode.element,
+      bmp: styledNode.bmp
+    )
 
-when defined(debug):
-  proc computedTree*(styledNode: StyledNode; ctx: var TreeContext): string =
-    result = ""
-    if styledNode.t != stElement:
-      result &= $styledNode
-    else:
-      result &= "<"
-      if styledNode.computed{"display"} != DisplayInline:
-        result &= "div"
-      else:
-        result &= "span"
-      let computed = styledNode.computed.copyProperties()
-      if computed{"display"} == DisplayBlock:
-        computed{"display"} = DisplayInline
-      result &= " style='" & $computed.serializeEmpty() & "'>\n"
-      for it in styledNode.children(ctx):
-        result &= it.computedTree(ctx)
-      result &= "\n</div>"
-
-  proc computedTree*(styledNode: StyledNode): string =
-    var ctx = TreeContext()
-    return styledNode.computedTree(ctx)
+# Root
+proc buildTree*(element: Element; cached: CSSBox): BlockBox =
+  if element.computed == nil:
+    element.applyStyle()
+  let styledNode = StyledNode(
+    t: stElement,
+    element: element,
+    computed: element.computed
+  )
+  var ctx = TreeContext(rootProperties: rootProperties())
+  return BlockBox(ctx.build(cached, styledNode))
diff --git a/src/css/cssvalues.nim b/src/css/cssvalues.nim
index a183d6fb..15e81303 100644
--- a/src/css/cssvalues.nim
+++ b/src/css/cssvalues.nim
@@ -12,6 +12,7 @@ import html/catom
 import types/bitmap
 import types/color
 import types/opt
+import types/refstring
 import types/winattrs
 import utils/twtstr
 
@@ -354,11 +355,11 @@ type
 
   CSSContent* = object
     t*: CSSContentType
-    s*: string
+    s*: RefString
 
   # nil -> auto
   CSSQuotes* = ref object
-    qs*: seq[tuple[s, e: string]]
+    qs*: seq[tuple[s, e: RefString]]
 
   CSSCounterReset* = object
     name*: string
@@ -533,9 +534,10 @@ const InheritedProperties = {
 const OverflowScrollLike* = {OverflowScroll, OverflowAuto, OverflowOverlay}
 const OverflowHiddenLike* = {OverflowHidden, OverflowClip}
 const FlexReverse* = {FlexDirectionRowReverse, FlexDirectionColumnReverse}
-const DisplayOuterInline* = {
-  DisplayInline, DisplayInlineTable, DisplayInlineBlock, DisplayInlineFlex
+const DisplayInlineBlockLike* = {
+  DisplayInlineTable, DisplayInlineBlock, DisplayInlineFlex
 }
+const DisplayOuterInline* = DisplayInlineBlockLike + {DisplayInline}
 const DisplayInnerFlex* = {DisplayFlex, DisplayInlineFlex}
 const RowGroupBox* = {
   # Note: caption is not included here
@@ -596,7 +598,7 @@ func `$`*(bmp: NetworkBitmap): string =
 
 func `$`*(content: CSSContent): string =
   if content.s != "":
-    return "url(" & content.s & ")"
+    return content.s
   return "none"
 
 func `$`(quotes: CSSQuotes): string =
@@ -604,7 +606,7 @@ func `$`(quotes: CSSQuotes): string =
     return "auto"
   result = ""
   for (s, e) in quotes.qs:
-    result &= "'" & s.cssEscape() & "' '" & e.cssEscape() & "'"
+    result &= "'" & ($s).cssEscape() & "' '" & ($e).cssEscape() & "'"
 
 func `$`(counterreset: seq[CSSCounterReset]): string =
   result = ""
@@ -1257,7 +1259,7 @@ func parseQuotes(cvals: openArray[CSSComponentValue]): Opt[CSSQuotes] =
       if tok.t != cttString:
         return err()
       if otok != nil:
-        res.qs.add((otok.value, tok.value))
+        res.qs.add((newRefString(otok.value), newRefString(tok.value)))
         otok = nil
       else:
         otok = tok
@@ -1286,7 +1288,7 @@ func cssContent(cvals: openArray[CSSComponentValue]): seq[CSSContent] =
         elif tok.value.equalsIgnoreCase("no-close-quote"):
           result.add(CSSContent(t: ContentNoCloseQuote))
       of cttString:
-        result.add(CSSContent(t: ContentString, s: tok.value))
+        result.add(CSSContent(t: ContentString, s: newRefString(tok.value)))
       else: return
 
 func parseFontWeight(cval: CSSComponentValue): Opt[int32] =
diff --git a/src/css/layout.nim b/src/css/layout.nim
index 0153b7e8..ba74b2bd 100644
--- a/src/css/layout.nim
+++ b/src/css/layout.nim
@@ -2,7 +2,6 @@ import std/algorithm
 import std/math
 
 import css/box
-import css/csstree
 import css/cssvalues
 import css/lunit
 import html/dom
@@ -46,7 +45,6 @@ type
     attrsp: ptr WindowAttributes
     cellSize: Size # size(w = attrsp.ppc, h = attrsp.ppl)
     positioned: seq[PositionedItem]
-    myRootProperties: CSSValues
     luctx: LUContext
 
 const DefaultSpan = Span(start: 0, send: LUnit.high)
@@ -122,7 +120,6 @@ func establishesBFC(computed: CSSValues): bool =
     computed{"overflow-x"} notin {OverflowVisible, OverflowClip}
     #TODO contain, grid, multicol, column-span
 
-# 2nd pass: layout
 func canpx(l: CSSLength; sc: SizeConstraint): bool =
   return l.u != clAuto and (l.u != clPerc or sc.t == scStretch)
 
@@ -176,7 +173,7 @@ func minClamp(x: LUnit; span: Span): LUnit =
 # Flow is rooted in any block box that establishes a Block Formatting
 # Context (BFC)[1].  State associated with these is represented by the
 # BlockContext object.
-# Then, flow includes further child "boxes"[1] of the following types:
+# Then, flow includes further child "boxes"[2] of the following types:
 #
 # * Inline.  These may contain further inline boxes, text, images,
 #   or block boxes (!).
@@ -236,26 +233,6 @@ func minClamp(x: LUnit; span: Span): LUnit =
 #       * 3
 #
 # and blocks that come after inlines simply flush the current line box.
-# Note however, that the following fragment:
-#
-# <div id=a><span id=b>1</span><div id=c>2</div><span id=d>3</span></div>
-#
-# still produces this tree:
-#
-# * div#a
-#   * anonymous block
-#     * span#b
-#       * 1
-#   * div#c
-#     * anonymous inline
-#       * 2
-#   * anonymous block
-#     * span#d
-#       * 3
-#
-# This is an artifact of the previous implementation, which assumed that
-# inlines and blocks cannot be mixed together.  I'm not yet sure if this
-# should be changed.
 #
 # [3]: The spec itself does not even mention this case, but there is a
 # resolution that agrees with our new implementation:
@@ -391,7 +368,7 @@ type
     # Inline context state:
     lbstate: LineBoxState
     whitespacenum: int
-    whitespaceBox: InlineBox
+    whitespaceBox: InlineTextBox
     word: InlineAtomState
     wrappos: int # position of last wrapping opportunity, or -1
     lastTextBox: InlineBox
@@ -554,9 +531,10 @@ func computeShift(fstate: FlowState; istate: InlineState): LUnit =
     if fstate.lbstate.iastates.len == 0:
       return 0
     let ibox = fstate.lbstate.iastates[^1].ibox
-    if ibox.t == ibtText and ibox.runs.len > 0 and
-        ibox.runs[^1].str[^1] == ' ':
-      return 0
+    if ibox of InlineTextBox:
+      let ibox = InlineTextBox(ibox)
+      if ibox.runs.len > 0 and ibox.runs[^1].str[^1] == ' ':
+        return 0
   return fstate.cellWidth * fstate.whitespacenum
 
 proc newWord(fstate: var FlowState; ibox: InlineBox) =
@@ -645,13 +623,15 @@ proc alignLine(fstate: var FlowState) =
       # init new box
       currentBox = box
       currentAreaOffsetX = iastate.offset.x
-    case iastate.ibox.t
-    of ibtBox:
-      iastate.ibox.box.state.offset += iastate.offset
-    of ibtBitmap:
-      iastate.ibox.image.state.offset += iastate.offset
-    of ibtText:
+    if iastate.ibox of InlineTextBox:
       iastate.run.offset = iastate.offset
+    elif iastate.ibox of InlineBlockBox:
+      let ibox = InlineBlockBox(iastate.ibox)
+      # Add the offset to avoid destroying margins (etc.) of the block.
+      ibox.box.state.offset += iastate.offset
+    elif iastate.ibox of InlineImageBox:
+      let ibox = InlineImageBox(iastate.ibox)
+      ibox.imgstate.offset = iastate.offset
     else:
       assert false
   if currentBox != nil:
@@ -678,8 +658,9 @@ proc alignLine(fstate: var FlowState) =
 
 proc putAtom(lbstate: var LineBoxState; iastate: InlineAtomState) =
   lbstate.iastates.add(iastate)
-  if iastate.ibox.t == ibtText:
-    iastate.ibox.runs.add(iastate.run)
+  if iastate.ibox of InlineTextBox:
+    let ibox = InlineTextBox(iastate.ibox)
+    ibox.runs.add(iastate.run)
 
 proc addSpacing(fstate: var FlowState; width: LUnit; hang = false) =
   let ibox = fstate.whitespaceBox
@@ -866,18 +847,21 @@ proc addAtom(fstate: var FlowState; istate: var InlineState;
       fstate.initLine()
       # Recompute on newline
       shift = fstate.computeShift(istate)
-  if iastate.size.w > 0 and iastate.size.h > 0 or iastate.ibox.t == ibtBox:
+  if iastate.size.w > 0 and iastate.size.h > 0 or
+      iastate.ibox of InlineBlockBox:
     if shift > 0:
       fstate.addSpacing(shift)
     if iastate.run != nil and fstate.lbstate.iastates.len > 0 and
-        istate.ibox.runs.len > 0:
-      let oiastate = addr fstate.lbstate.iastates[^1]
-      let orun = oiastate.run
-      if orun != nil and orun == istate.ibox.runs[^1]:
-        orun.str &= iastate.run.str
-        oiastate.size.w += iastate.size.w
-        fstate.lbstate.size.w += iastate.size.w
-        return
+        istate.ibox of InlineTextBox:
+      let ibox = InlineTextBox(istate.ibox)
+      if ibox.runs.len > 0:
+        let oiastate = addr fstate.lbstate.iastates[^1]
+        let orun = oiastate.run
+        if orun != nil and orun == ibox.runs[^1]:
+          orun.str &= iastate.run.str
+          oiastate.size.w += iastate.size.w
+          fstate.lbstate.size.w += iastate.size.w
+          return
     fstate.lbstate.putAtom(iastate)
     fstate.lbstate.iastates[^1].offset.x += fstate.lbstate.size.w
     fstate.lbstate.size.w += iastate.size.w
@@ -961,45 +945,46 @@ proc checkWrap(fstate: var FlowState; state: var InlineState; u: uint32;
       fstate.finishLine(state, wrap = true)
       fstate.whitespacenum = 0
 
-proc processWhitespace(fstate: var FlowState; state: var InlineState;
+proc processWhitespace(fstate: var FlowState; istate: var InlineState;
     c: char) =
-  discard fstate.addWord(state)
-  case state.ibox.computed{"white-space"}
+  let ibox = InlineTextBox(istate.ibox)
+  discard fstate.addWord(istate)
+  case ibox.computed{"white-space"}
   of WhitespaceNormal, WhitespaceNowrap:
     if fstate.whitespacenum < 1 and fstate.lbstate.iastates.len > 0:
       fstate.whitespacenum = 1
-      fstate.whitespaceBox = state.ibox
+      fstate.whitespaceBox = ibox
       fstate.whitespaceIsLF = c == '\n'
     if c != '\n':
       fstate.whitespaceIsLF = false
   of WhitespacePreLine:
     if c == '\n':
-      fstate.finishLine(state, wrap = false, force = true)
+      fstate.finishLine(istate, wrap = false, force = true)
     elif fstate.whitespacenum < 1:
       fstate.whitespaceIsLF = false
       fstate.whitespacenum = 1
-      fstate.whitespaceBox = state.ibox
+      fstate.whitespaceBox = ibox
   of WhitespacePre, WhitespacePreWrap:
     fstate.whitespaceIsLF = false
     if c == '\n':
-      fstate.finishLine(state, wrap = false, force = true)
+      fstate.finishLine(istate, wrap = false, force = true)
     elif c == '\t':
       let realWidth = fstate.lbstate.charwidth + fstate.whitespacenum
       # We must flush first, because addWord would otherwise try to wrap the
       # line. (I think.)
-      fstate.flushWhitespace(state)
+      fstate.flushWhitespace(istate)
       let w = ((realWidth + 8) and not 7) - realWidth
       fstate.word.run.str.addUTF8(tabPUAPoint(w))
       fstate.word.size.w += w * fstate.cellWidth
       fstate.lbstate.charwidth += w
       # Ditto here - we don't want the tab stop to get merged into the next
       # word.
-      discard fstate.addWord(state)
+      discard fstate.addWord(istate)
     else:
       inc fstate.whitespacenum
-      fstate.whitespaceBox = state.ibox
+      fstate.whitespaceBox = ibox
   # set the "last word's last rune width" to the previous rune width
-  state.lastrw = state.prevrw
+  istate.lastrw = istate.prevrw
 
 proc layoutTextLoop(fstate: var FlowState; state: var InlineState;
     str: string) =
@@ -1717,103 +1702,102 @@ proc layoutOuterBlock(fstate: var FlowState; child: BlockBox;
       newLine: newLine
     ))
 
-proc addInlineBlock(fstate: var FlowState; istate: var InlineState;
-    box: BlockBox) =
-  let lctx = fstate.lctx
-  var sizes = lctx.resolveFloatSizes(fstate.space, box.computed)
-  lctx.roundSmallMarginsAndPadding(sizes)
-  lctx.layoutRootBlock(box, sizes.margin.topLeft, sizes)
-  # Apply the block box's properties to the atom itself.
-  let iastate = InlineAtomState(
-    ibox: istate.ibox,
-    baseline: box.state.baseline + sizes.margin.top,
-    vertalign: box.computed{"vertical-align"},
-    size: size(
-      w = box.outerSize(dtHorizontal, sizes),
-      h = box.outerSize(dtVertical, sizes) + box.state.marginBottom
-    )
-  )
-  discard fstate.addAtom(istate, iastate)
-  fstate.intr.w = max(fstate.intr.w, box.state.intr.w)
-  fstate.lbstate.intrh = max(fstate.lbstate.intrh, iastate.size.h)
-  fstate.lbstate.charwidth = 0
-  fstate.whitespacenum = 0
-
-proc addBox(fstate: var FlowState; state: var InlineState; box: BlockBox) =
-  # Absolute is a bit of a special case in inline.
-  if box.computed{"position"} notin PositionAbsoluteFixed and
-      box.computed{"display"} in DisplayOuterInline:
-    fstate.addInlineBlock(state, box)
-  else:
-    assert box.computed{"position"} in PositionAbsoluteFixed
-    var textAlign = state.ibox.computed{"text-align"}
+proc layoutInlineBlock(fstate: var FlowState; ibox: InlineBlockBox) =
+  let box = ibox.box
+  if box.computed{"position"} in PositionAbsoluteFixed:
+    # Absolute is a bit of a special case in inline: while the spec
+    # *says* it should blockify, absolutely positioned inline-blocks are
+    # placed in a different place than absolutely positioned blocks (and
+    # websites depend on this).
+    var textAlign = ibox.computed{"text-align"}
     if not fstate.space.w.isDefinite():
       # Aligning min-content or max-content is nonsensical.
       textAlign = TextAlignLeft
     fstate.layoutOuterBlock(box, textAlign)
+  else:
+    # A real inline block.
+    let lctx = fstate.lctx
+    var sizes = lctx.resolveFloatSizes(fstate.space, box.computed)
+    lctx.roundSmallMarginsAndPadding(sizes)
+    lctx.layoutRootBlock(box, sizes.margin.topLeft, sizes)
+    # Apply the block box's properties to the atom itself.
+    let iastate = InlineAtomState(
+      ibox: ibox,
+      baseline: box.state.baseline + sizes.margin.top,
+      vertalign: box.computed{"vertical-align"},
+      size: size(
+        w = box.outerSize(dtHorizontal, sizes),
+        h = box.outerSize(dtVertical, sizes) + box.state.marginBottom
+      )
+    )
+    var istate = InlineState(ibox: ibox)
+    discard fstate.addAtom(istate, iastate)
+    fstate.intr.w = max(fstate.intr.w, box.state.intr.w)
+    fstate.lbstate.intrh = max(fstate.lbstate.intrh, iastate.size.h)
+    fstate.lbstate.charwidth = 0
+    fstate.whitespacenum = 0
 
-proc addImage(fstate: var FlowState; state: var InlineState;
-    image: InlineImage; padding: LUnit) =
-  #TODO add state to image
-  image.state = InlineImageState(
-    size: size(w = image.bmp.width, h = image.bmp.height)
+proc layoutImage(fstate: var FlowState; ibox: InlineImageBox; padding: LUnit) =
+  ibox.imgstate = InlineImageState(
+    size: size(w = ibox.bmp.width, h = ibox.bmp.height)
   )
   #TODO this is hopelessly broken.
   # The core problem is that we generate an inner and an outer box for
   # images, and achieving an acceptable image sizing algorithm with this
-  # setup is practicaully impossible.
+  # setup is practically impossible.
   # Accordingly, a correct solution would either handle block-level
   # images separately, or at least resolve the outer box's sizes with
   # the knowledge that it is an image.
-  let computed = state.ibox.computed
+  let computed = ibox.computed
   let hasWidth = computed{"width"}.canpx(fstate.space.w)
   let hasHeight = computed{"height"}.canpx(fstate.space.h)
-  let osize = image.state.size
+  let osize = ibox.imgstate.size
   if hasWidth:
-    image.state.size.w = computed{"width"}.spx(fstate.space.w, computed,
+    ibox.imgstate.size.w = computed{"width"}.spx(fstate.space.w, computed,
       padding)
   if hasHeight:
-    image.state.size.h = computed{"height"}.spx(fstate.space.h, computed,
+    ibox.imgstate.size.h = computed{"height"}.spx(fstate.space.h, computed,
       padding)
   if computed{"max-width"}.canpx(fstate.space.w):
     let w = computed{"max-width"}.spx(fstate.space.w, computed, padding)
-    image.state.size.w = min(image.state.size.w, w)
+    ibox.imgstate.size.w = min(ibox.imgstate.size.w, w)
   let hasMinWidth = computed{"min-width"}.canpx(fstate.space.w)
   if hasMinWidth:
     let w = computed{"min-width"}.spx(fstate.space.w, computed, padding)
-    image.state.size.w = max(image.state.size.w, w)
+    ibox.imgstate.size.w = max(ibox.imgstate.size.w, w)
   if computed{"max-height"}.canpx(fstate.space.h):
     let h = computed{"max-height"}.spx(fstate.space.h, computed, padding)
-    image.state.size.h = min(image.state.size.h, h)
+    ibox.imgstate.size.h = min(ibox.imgstate.size.h, h)
   let hasMinHeight = computed{"min-height"}.canpx(fstate.space.h)
   if hasMinHeight:
     let h = computed{"min-height"}.spx(fstate.space.h, computed, padding)
-    image.state.size.h = max(image.state.size.h, h)
+    ibox.imgstate.size.h = max(ibox.imgstate.size.h, h)
   if not hasWidth and fstate.space.w.isDefinite():
-    image.state.size.w = min(fstate.space.w.u, image.state.size.w)
+    ibox.imgstate.size.w = min(fstate.space.w.u, ibox.imgstate.size.w)
   if not hasHeight and fstate.space.h.isDefinite():
-    image.state.size.h = min(fstate.space.h.u, image.state.size.h)
+    ibox.imgstate.size.h = min(fstate.space.h.u, ibox.imgstate.size.h)
   if not hasHeight and not hasWidth:
     if osize.w >= osize.h or
         not fstate.space.h.isDefinite() and fstate.space.w.isDefinite():
       if osize.w > 0:
-        image.state.size.h = osize.h div osize.w * image.state.size.w
+        ibox.imgstate.size.h = osize.h div osize.w * ibox.imgstate.size.w
     else:
       if osize.h > 0:
-        image.state.size.w = osize.w div osize.h * image.state.size.h
+        ibox.imgstate.size.w = osize.w div osize.h * ibox.imgstate.size.h
   elif not hasHeight and osize.w != 0:
-    image.state.size.h = osize.h div osize.w * image.state.size.w
+    ibox.imgstate.size.h = osize.h div osize.w * ibox.imgstate.size.w
   elif not hasWidth and osize.h != 0:
-    image.state.size.w = osize.w div osize.h * image.state.size.h
+    ibox.imgstate.size.w = osize.w div osize.h * ibox.imgstate.size.h
   let iastate = InlineAtomState(
-    ibox: state.ibox,
-    vertalign: state.ibox.computed{"vertical-align"},
-    baseline: image.state.size.h,
-    size: image.state.size
+    ibox: ibox,
+    vertalign: ibox.computed{"vertical-align"},
+    baseline: ibox.imgstate.size.h,
+    size: ibox.imgstate.size
   )
-  discard fstate.addAtom(state, iastate)
+  var istate = InlineState(ibox: ibox)
+  discard fstate.addAtom(istate, iastate)
   fstate.lbstate.charwidth = 0
-  if image.state.size.h > 0:
+  if ibox.imgstate.size.h > 0:
     # Setting the atom size as intr.w might result in a circular dependency
     # between table cell sizing and image sizing when we don't have a definite
     # parent size yet. e.g. <img width=100% ...> with an indefinite containing
@@ -1824,90 +1808,96 @@ proc addImage(fstate: var FlowState; state: var InlineState;
     # So check if any dimension is fixed, and if yes, report the intrinsic
     # minimum dimension as that or the atom size (whichever is greater).
     if computed{"width"}.u != clPerc or computed{"min-width"}.u != clPerc:
-      fstate.intr.w = max(fstate.intr.w, image.state.size.w)
+      fstate.intr.w = max(fstate.intr.w, ibox.imgstate.size.w)
     if computed{"height"}.u != clPerc or computed{"min-height"}.u != clPerc:
-      fstate.lbstate.intrh = max(fstate.lbstate.intrh, image.state.size.h)
+      fstate.lbstate.intrh = max(fstate.lbstate.intrh, ibox.imgstate.size.h)
 
 proc layoutInline(fstate: var FlowState; ibox: InlineBox) =
   let lctx = fstate.lctx
   let computed = ibox.computed
-  var padding = Span()
-  if ibox.t == ibtParent:
+  ibox.state = InlineBoxState(
+    startOffset: offset(
+      x = fstate.lbstate.widthAfterWhitespace,
+      y = fstate.offset.y
+    )
+  )
+  let padding = Span(
+    start: computed{"padding-left"}.px(fstate.space.w),
+    send: computed{"padding-right"}.px(fstate.space.w)
+  )
+  if ibox of InlineTextBox:
+    let ibox = InlineTextBox(ibox)
+    ibox.runs.setLen(0)
+    var istate = InlineState(ibox: ibox)
+    fstate.layoutText(istate, ibox.text)
+    fstate.lastTextBox = ibox
+  elif ibox of InlineNewLineBox:
+    let ibox = InlineNewLineBox(ibox)
+    var istate = InlineState(ibox: ibox)
+    fstate.finishLine(istate, wrap = false, force = true,
+      ibox.computed{"clear"})
+    fstate.lastTextBox = ibox
+  elif ibox of InlineBlockBox:
+    let ibox = InlineBlockBox(ibox)
+    fstate.layoutInlineBlock(ibox)
+    fstate.lastTextBox = ibox
+  elif ibox of InlineImageBox:
+    let ibox = InlineImageBox(ibox)
+    fstate.layoutImage(ibox, padding.sum())
+    fstate.lastTextBox = ibox
+  else:
     let w = computed{"margin-left"}.px(fstate.space.w)
     if w != 0:
       fstate.initLine()
       fstate.lbstate.size.w += w
       fstate.lbstate.widthAfterWhitespace += w
-    padding = Span(
-      start: computed{"padding-left"}.px(fstate.space.w),
-      send: computed{"padding-right"}.px(fstate.space.w)
-    )
-  ibox.state = InlineBoxState()
-  if padding.start != 0:
-    ibox.state.areas.add(Area(
-      offset: offset(x = fstate.lbstate.widthAfterWhitespace, y = 0),
-      size: size(w = padding.start, h = fstate.cellHeight)
-    ))
-    fstate.lbstate.paddingTodo.add((ibox, 0))
-  ibox.state.startOffset = offset(
-    x = fstate.lbstate.widthAfterWhitespace,
-    y = fstate.offset.y
-  )
-  if padding.start != 0:
-    fstate.initLine()
-    fstate.lbstate.size.w += padding.start
-  var state = InlineState(ibox: ibox)
-  if ibox.t == ibtParent and computed{"position"} != PositionStatic:
-    lctx.pushPositioned()
-  case ibox.t
-  of ibtNewline:
-    fstate.finishLine(state, wrap = false, force = true,
-      ibox.computed{"clear"})
-  of ibtBox: fstate.addBox(state, ibox.box)
-  of ibtBitmap: fstate.addImage(state, ibox.image, padding.sum())
-  of ibtText:
-    fstate.layoutText(state, ibox.text.data)
-  of ibtParent:
+      ibox.state.startOffset.x += w
+    if padding.start != 0:
+      ibox.state.areas.add(Area(
+        offset: offset(x = fstate.lbstate.widthAfterWhitespace, y = 0),
+        size: size(w = padding.start, h = fstate.cellHeight)
+      ))
+      fstate.lbstate.paddingTodo.add((ibox, 0))
+      fstate.initLine()
+      fstate.lbstate.size.w += padding.start
+    if computed{"position"} != PositionStatic:
+      lctx.pushPositioned()
     for child in ibox.children:
       if child of InlineBox:
         fstate.layoutInline(InlineBox(child))
       else:
         # It seems -moz-center uses the inline parent too...  which is
-        # nonsense if you consider the CSS 2 anonymous box generation rules,
-        # but whatever.
-        var textAlign = state.ibox.computed{"text-align"}
+        # nonsense if you consider the CSS 2 anonymous box generation
+        # rules, but whatever.
+        var textAlign = ibox.computed{"text-align"}
         if not fstate.space.w.isDefinite():
           # Aligning min-content or max-content is nonsensical.
           textAlign = TextAlignLeft
         fstate.layoutOuterBlock(BlockBox(child), textAlign)
-  if padding.send != 0:
-    ibox.state.areas.add(Area(
-      offset: offset(x = fstate.lbstate.size.w, y = 0),
-      size: size(w = padding.send, h = fstate.cellHeight)
-    ))
-    fstate.lbstate.paddingTodo.add((ibox, ibox.state.areas.high))
-  if ibox.t == ibtParent:
     if padding.send != 0:
+      ibox.state.areas.add(Area(
+        offset: offset(x = fstate.lbstate.size.w, y = 0),
+        size: size(w = padding.send, h = fstate.cellHeight)
+      ))
+      fstate.lbstate.paddingTodo.add((ibox, ibox.state.areas.high))
       fstate.initLine()
       fstate.lbstate.size.w += padding.send
     let marginRight = computed{"margin-right"}.px(fstate.space.w)
     if marginRight != 0:
       fstate.initLine()
       fstate.lbstate.size.w += marginRight
-  if ibox.t != ibtParent:
-    fstate.lastTextBox = ibox
-  if ibox.t == ibtParent and computed{"position"} != PositionStatic:
-    # This is UB in CSS 2.1, I can't find a newer spec about it,
-    # and Gecko can't even layout it consistently (???)
-    #
-    # So I'm trying to follow Blink, though it's still not quite right,
-    # since it uses cellHeight instead of the actual line height for the
-    # last line.
-    # Well, it seems good enough.
-    lctx.popPositioned(size(
-      w = 0,
-      h = fstate.offset.y + fstate.cellHeight - ibox.state.startOffset.y
-    ))
+    if computed{"position"} != PositionStatic:
+      # This is UB in CSS 2.1, I can't find a newer spec about it,
+      # and Gecko can't even layout it consistently (???)
+      #
+      # So I'm trying to follow Blink, though it's still not quite right,
+      # since it uses cellHeight instead of the actual line height for the
+      # last line.
+      # Well, it seems good enough.
+      lctx.popPositioned(size(
+        w = 0,
+        h = fstate.offset.y + fstate.cellHeight - ibox.state.startOffset.y
+      ))
 
 proc layoutFlow0(fstate: var FlowState; sizes: ResolvedSizes; box: BlockBox) =
   fstate.lbstate = fstate.initLineBoxState()
@@ -2863,82 +2853,7 @@ proc layoutRootBlock(lctx: LayoutContext; box: BlockBox; offset: Offset;
   box.state.intr.h = max(box.state.intr.h, bctx.maxFloatHeight)
   box.state.marginBottom = marginBottom
 
-# 1st pass: build tree
-#TODO this tree traversal is largely redundant, and should be moved
-# to csstree.  (Then it should be collapsed into the second pass by
-# lazily generating child boxes, taking boxes from the previous pass
-# when possible.)
-
-proc build(ctx: var TreeContext; styledNode: StyledNode;
-  children: var seq[CSSBox])
-
-proc buildInline(ctx: var TreeContext; styledNode: StyledNode): InlineBox =
-  let ibox = InlineBox(
-    t: ibtParent,
-    computed: styledNode.computed,
-    element: styledNode.element
-  )
-  ctx.build(styledNode, ibox.children)
-  return ibox
-
-proc buildBlock(ctx: var TreeContext; styledNode: StyledNode): BlockBox =
-  let box = BlockBox(computed: styledNode.computed, element: styledNode.element)
-  ctx.build(styledNode, box.children)
-  return box
-
-proc buildInlineBlock(ctx: var TreeContext; styledNode: StyledNode): InlineBox =
-  return InlineBox(
-    t: ibtBox,
-    computed: styledNode.computed.inheritProperties(),
-    element: styledNode.element,
-    box: ctx.buildBlock(styledNode)
-  )
-
-proc buildFromElem(ctx: var TreeContext; styledNode: StyledNode): CSSBox =
-  return case styledNode.computed{"display"}
-  of DisplayBlock, DisplayFlowRoot, DisplayFlex, DisplayTable,
-      DisplayTableCaption, DisplayTableCell, RowGroupBox, DisplayTableRow,
-      DisplayTableWrapper, DisplayListItem:
-    ctx.buildBlock(styledNode)
-  of DisplayInlineBlock, DisplayInlineTable, DisplayInlineFlex:
-    ctx.buildInlineBlock(styledNode)
-  of DisplayInline: ctx.buildInline(styledNode)
-  of DisplayTableColumn, DisplayTableColumnGroup, #TODO
-      DisplayNone:
-    assert false
-    nil
-
-proc build(ctx: var TreeContext; styledNode: StyledNode;
-    children: var seq[CSSBox]) =
-  for child in styledNode.children(ctx):
-    case child.t
-    of stElement:
-      children.add(ctx.buildFromElem(child))
-    of stText:
-      children.add(InlineBox(
-        t: ibtText,
-        computed: child.computed,
-        element: child.element,
-        text: child.text
-      ))
-    of stBr:
-      children.add(InlineBox(
-        t: ibtNewline,
-        computed: child.computed,
-        element: child.element
-      ))
-    of stImage:
-      children.add(InlineBox(
-        t: ibtBitmap,
-        computed: child.computed,
-        element: child.element,
-        image: InlineImage(bmp: child.bmp)
-      ))
-
-proc layout*(root: StyledNode; attrsp: ptr WindowAttributes): BlockBox =
-  let box = BlockBox(computed: root.computed, element: root.element)
-  var ctx = TreeContext()
-  ctx.build(root, box.children)
+proc layout*(box: BlockBox; attrsp: ptr WindowAttributes) =
   let space = availableSpace(
     w = stretch(attrsp[].widthPx),
     h = stretch(attrsp[].heightPx)
@@ -2947,7 +2862,6 @@ proc layout*(root: StyledNode; attrsp: ptr WindowAttributes): BlockBox =
     attrsp: attrsp,
     cellSize: size(w = attrsp.ppc, h = attrsp.ppl),
     positioned: @[PositionedItem(), PositionedItem()],
-    myRootProperties: rootProperties(),
     luctx: LUContext()
   )
   let sizes = lctx.resolveBlockSizes(space, box.computed)
@@ -2965,4 +2879,3 @@ proc layout*(root: StyledNode; attrsp: ptr WindowAttributes): BlockBox =
   size.w = max(size.w, box.state.size.w)
   size.h = max(size.h, box.state.size.h)
   lctx.popPositioned(size)
-  return box
diff --git a/src/css/render.nim b/src/css/render.nim
index 241167bb..445896ac 100644
--- a/src/css/render.nim
+++ b/src/css/render.nim
@@ -360,43 +360,40 @@ proc paintInlineBox(grid: var FlexibleGrid; state: var RenderState;
       alpha)
 
 proc renderInlineBox(grid: var FlexibleGrid; state: var RenderState;
-    box: InlineBox; offset: Offset; bgcolor0: ARGBColor;
+    ibox: InlineBox; offset: Offset; bgcolor0: ARGBColor;
     pass2 = false) =
-  let position = box.computed{"position"}
+  let position = ibox.computed{"position"}
   #TODO stacking contexts
-  let bgcolor = box.computed{"background-color"}
+  let bgcolor = ibox.computed{"background-color"}
   var bgcolor0 = bgcolor0
   if bgcolor.isCell:
     let bgcolor = bgcolor.cellColor()
     if bgcolor.t != ctNone:
-      grid.paintInlineBox(state, box, offset, bgcolor, 255)
+      grid.paintInlineBox(state, ibox, offset, bgcolor, 255)
   else:
     bgcolor0 = bgcolor0.blend(bgcolor.argb)
     if bgcolor0.a > 0:
-      grid.paintInlineBox(state, box, offset,
+      grid.paintInlineBox(state, ibox, offset,
         bgcolor0.rgb.cellColor(), bgcolor0.a)
-  let startOffset = offset + box.state.startOffset
-  box.render.offset = startOffset
-  case box.t
-  of ibtParent:
-    if position != PositionStatic:
-      state.absolutePos.add(startOffset)
-    for child in box.children:
-      if child of InlineBox:
-        grid.renderInlineBox(state, InlineBox(child), offset, bgcolor0)
-      else:
-        grid.renderBlockBox(state, BlockBox(child), offset)
-    if position != PositionStatic:
-      discard state.absolutePos.pop()
-  of ibtBox:
-    grid.renderBlockBox(state, box.box, offset)
-  of ibtBitmap:
-    if box.computed{"visibility"} != VisibilityVisible:
+  let startOffset = offset + ibox.state.startOffset
+  ibox.render.offset = startOffset
+  if ibox of InlineTextBox:
+    let ibox = InlineTextBox(ibox)
+    let format = ibox.computed.toFormat()
+    for run in ibox.runs:
+      let offset = offset + run.offset
+      if ibox.computed{"visibility"} == VisibilityVisible:
+        grid.setText(state, run.str, offset, format, ibox.element)
+  elif ibox of InlineBlockBox:
+    let ibox = InlineBlockBox(ibox)
+    grid.renderBlockBox(state, ibox.box, offset)
+  elif ibox of InlineImageBox:
+    let ibox = InlineImageBox(ibox)
+    if ibox.computed{"visibility"} != VisibilityVisible:
       return
-    let image = box.image
-    let offset = offset + image.state.offset
-    let x2p = offset.x + image.state.size.w
-    let y2p = offset.y + image.state.size.h
+    let offset = offset + ibox.imgstate.offset
+    let x2p = offset.x + ibox.imgstate.size.w
+    let y2p = offset.y + ibox.imgstate.size.h
     let clipBox = addr state.clipBoxes[^1]
     #TODO implement proper image clipping
     if offset.x < clipBox.send.x and offset.y < clipBox.send.y and
@@ -407,7 +404,7 @@ proc renderInlineBox(grid: var FlexibleGrid; state: var RenderState;
       let y2 = y2p.toInt
       # add Element to background (but don't actually color it)
       grid.paintBackground(state, defaultColor, x1, y1, x2, y2,
-        box.element, 0)
+        ibox.element, 0)
       let x = (offset.x div state.attrs.ppc).toInt
       let y = (offset.y div state.attrs.ppl).toInt
       let offx = (offset.x - x.toLUnit * state.attrs.ppc).toInt
@@ -417,18 +414,22 @@ proc renderInlineBox(grid: var FlexibleGrid; state: var RenderState;
         y: y,
         offx: offx,
         offy: offy,
-        width: image.state.size.w.toInt,
-        height: image.state.size.h.toInt,
-        bmp: image.bmp
+        width: ibox.imgstate.size.w.toInt,
+        height: ibox.imgstate.size.h.toInt,
+        bmp: ibox.bmp
       ))
-  of ibtText:
-    let format = box.computed.toFormat()
-    for run in box.runs:
-      let offset = offset + run.offset
-      if box.computed{"visibility"} == VisibilityVisible:
-        grid.setText(state, run.str, offset, format, box.element)
-  of ibtNewline:
+  elif ibox of InlineNewLineBox:
     discard
+  else:
+    if position != PositionStatic:
+      state.absolutePos.add(startOffset)
+    for child in ibox.children:
+      if child of InlineBox:
+        grid.renderInlineBox(state, InlineBox(child), offset, bgcolor0)
+      else:
+        grid.renderBlockBox(state, BlockBox(child), offset)
+    if position != PositionStatic:
+      discard state.absolutePos.pop()
 
 proc renderBlockBox(grid: var FlexibleGrid; state: var RenderState;
     box: BlockBox; offset: Offset; pass2 = false) =
diff --git a/src/html/chadombuilder.nim b/src/html/chadombuilder.nim
index b6eaab9b..91230d12 100644
--- a/src/html/chadombuilder.nim
+++ b/src/html/chadombuilder.nim
@@ -13,6 +13,7 @@ import monoucha/fromjs
 import monoucha/javascript
 import monoucha/jserror
 import types/opt
+import types/refstring
 import types/url
 
 export htmlparser.ParseResult
diff --git a/src/html/dom.nim b/src/html/dom.nim
index e628da6e..28859e27 100644
--- a/src/html/dom.nim
+++ b/src/html/dom.nim
@@ -48,6 +48,7 @@ import types/color
 import types/opt
 import types/path
 import types/referrer
+import types/refstring
 import types/url
 import types/winattrs
 import utils/strwidth
@@ -244,7 +245,7 @@ type
   XMLDocument = ref object of Document
 
   CharacterData* = ref object of Node
-    data* {.jsgetset.}: string
+    data* {.jsgetset.}: RefString
 
   Text* = ref object of CharacterData
 
@@ -309,7 +310,7 @@ type
 
   HTMLInputElement* = ref object of FormAssociatedElement
     inputType* {.jsget: "type".}: InputType
-    internalValue: CharacterData
+    internalValue: RefString
     internalChecked {.jsget: "checked".}: bool
     files* {.jsget.}: seq[WebFile]
     xcoord*: int
@@ -2097,7 +2098,7 @@ func names(ctx: JSContext; map: NamedNodeMap): JSPropertyEnumList
   return list
 
 func length(characterData: CharacterData): uint32 {.jsfget.} =
-  return uint32(characterData.data.utf16Len)
+  return uint32(($characterData.data).utf16Len)
 
 func tagName(element: Element): string {.jsfget.} =
   result = element.prefix.toStr()
@@ -2597,9 +2598,6 @@ func serializeFragment*(node: Node): string =
   result = ""
   result.serializeFragment(node)
 
-func newCharacterData*(data: sink string = ""): CharacterData =
-  return CharacterData(data: data)
-
 # Element
 proc hash(element: Element): Hash =
   return hash(cast[pointer](element))
@@ -2989,8 +2987,8 @@ func jsForm(this: HTMLInputElement): HTMLFormElement {.jsfget: "form".} =
 
 func value*(this: HTMLInputElement): lent string =
   if this.internalValue == nil:
-    this.internalValue = newCharacterData()
-  return this.internalValue.data
+    this.internalValue = newRefString("")
+  return this.internalValue
 
 func jsValue(ctx: JSContext; this: HTMLInputElement): JSValue
     {.jsfget: "value".} =
@@ -2999,8 +2997,8 @@ func jsValue(ctx: JSContext; this: HTMLInputElement): JSValue
 
 proc `value=`*(this: HTMLInputElement; value: sink string) {.jsfset: "value".} =
   if this.internalValue == nil:
-    this.internalValue = CharacterData()
-  this.internalValue.data = value
+    this.internalValue = newRefString("")
+  this.internalValue.s = value
   this.invalidate()
 
 proc setType(this: HTMLInputElement; s: string) {.jsfset: "type".} =
@@ -3021,33 +3019,33 @@ proc setChecked*(input: HTMLInputElement; b: bool) {.jsfset: "checked".} =
   input.invalidate()
   input.internalChecked = b
 
-func inputString*(input: HTMLInputElement): CharacterData =
+func inputString*(input: HTMLInputElement): RefString =
   case input.inputType
   of itCheckbox, itRadio:
     if input.checked:
-      return newCharacterData("*")
-    return newCharacterData(" ")
+      return newRefString("*")
+    return newRefString(" ")
   of itSearch, itText, itEmail, itURL, itTel:
     if input.value.len == 20:
       return input.internalValue
-    return CharacterData(
-      data: input.value.padToWidth(int(input.attrulgz(satSize).get(20)))
+    return newRefString(
+      input.value.padToWidth(int(input.attrulgz(satSize).get(20)))
     )
   of itPassword:
     let n = int(input.attrulgz(satSize).get(20))
-    return newCharacterData('*'.repeat(input.value.len).padToWidth(n))
+    return newRefString('*'.repeat(input.value.len).padToWidth(n))
   of itReset:
     if input.attrb(satValue):
       return input.internalValue
-    return newCharacterData("RESET")
+    return newRefString("RESET")
   of itSubmit, itButton:
     if input.attrb(satValue):
       return input.internalValue
-    return newCharacterData("SUBMIT")
+    return newRefString("SUBMIT")
   of itFile:
     #TODO multiple files?
     let s = if input.files.len > 0: input.files[0].name else: ""
-    return newCharacterData(s.padToWidth(int(input.attrulgz(satSize).get(20))))
+    return newRefString(s.padToWidth(int(input.attrulgz(satSize).get(20))))
   else:
     return input.internalValue
 
@@ -3549,7 +3547,7 @@ func getSrc*(this: HTMLElement): tuple[src, contentType: string] =
 func newText*(document: Document; data: sink string): Text =
   return Text(
     internalDocument: document,
-    data: data,
+    data: newRefString(data),
     index: -1
   )
 
@@ -3560,7 +3558,7 @@ func newText(ctx: JSContext; data: sink string = ""): Text {.jsctor.} =
 func newCDATASection(document: Document; data: string): CDATASection =
   return CDATASection(
     internalDocument: document,
-    data: data,
+    data: newRefString(data),
     index: -1
   )
 
@@ -3569,7 +3567,7 @@ func newProcessingInstruction(document: Document; target: string;
   return ProcessingInstruction(
     internalDocument: document,
     target: target,
-    data: data,
+    data: newRefString(data),
     index: -1
   )
 
@@ -3583,7 +3581,7 @@ func newDocumentFragment(ctx: JSContext): DocumentFragment {.jsctor.} =
 func newComment(document: Document; data: sink string): Comment =
   return Comment(
     internalDocument: document,
-    data: data,
+    data: newRefString(data),
     index: -1
   )
 
@@ -5088,7 +5086,7 @@ proc setNodeValue(ctx: JSContext; node: Node; data: JSValue): Err[void]
     var res = ""
     if not JS_IsNull(data):
       ?ctx.fromJS(data, res)
-    CharacterData(node).data = move(res)
+    CharacterData(node).data = newRefString(move(res))
   elif node of Attr:
     var res = ""
     if not JS_IsNull(data):
diff --git a/src/server/buffer.nim b/src/server/buffer.nim
index 49ae89e3..fe3f2e93 100644
--- a/src/server/buffer.nim
+++ b/src/server/buffer.nim
@@ -47,6 +47,7 @@ import types/cell
 import types/color
 import types/formdata
 import types/opt
+import types/refstring
 import types/url
 import types/winattrs
 import utils/strwidth
@@ -767,14 +768,15 @@ proc resolveTask[T](buffer: Buffer; cmd: BufferCommand; res: T) =
   buffer.tasks[cmd] = 0
 
 proc maybeReshape(buffer: Buffer) =
-  if buffer.document == nil or buffer.document.documentElement == nil:
+  let document = buffer.document
+  if document == nil or document.documentElement == nil:
     return # not parsed yet, nothing to render
-  if buffer.document.invalid:
-    let root = initStyledElement(buffer.document.documentElement)
-    buffer.rootBox = root.layout(addr buffer.attrs)
+  if document.invalid:
+    buffer.rootBox = document.documentElement.buildTree(buffer.rootBox)
+    buffer.rootBox.layout(addr buffer.attrs)
     buffer.lines.renderDocument(buffer.bgcolor, buffer.rootBox,
       addr buffer.attrs, buffer.images)
-    buffer.document.invalid = false
+    document.invalid = false
     if buffer.hasTask(bcOnReshape):
       buffer.resolveTask(bcOnReshape)
     else:
@@ -794,7 +796,7 @@ proc processData0(buffer: Buffer; data: UnsafeSlice): bool =
     if data.len > 0:
       let lastChild = plaintext.lastChild
       if lastChild != nil and lastChild of Text:
-        Text(lastChild).data &= data
+        Text(lastChild).data.s &= data
       else:
         plaintext.insert(buffer.document.newText($data), nil)
       #TODO just invalidate document?
diff --git a/src/types/refstring.nim b/src/types/refstring.nim
new file mode 100644
index 00000000..55c98530
--- /dev/null
+++ b/src/types/refstring.nim
@@ -0,0 +1,26 @@
+import monoucha/fromjs
+import monoucha/javascript
+import monoucha/tojs
+import types/opt
+
+type RefString* = ref object
+  s*: string
+
+proc newRefString*(s: sink string): RefString =
+  return RefString(s: s)
+
+converter `$`*(rs: RefString): lent string =
+  rs.s
+
+template `&=`*(rs: var RefString; ss: string) =
+  rs.s &= ss
+
+proc toJS*(ctx: JSContext; rs: RefString): JSValue =
+  return ctx.toJS($rs)
+
+proc fromJS*(ctx: JSContext; val: JSValue; rs: out RefString): Opt[void] =
+  rs = RefString()
+  if ctx.fromJS(val, rs.s).isNone:
+    rs = nil
+    return err()
+  return ok()