diff --git a/content/blog/wsdiff-static-html-diffs/index.rst b/content/blog/wsdiff-static-html-diffs/index.rst
new file mode 100644
index 0000000..51f9175
--- /dev/null
+++ b/content/blog/wsdiff-static-html-diffs/index.rst
@@ -0,0 +1,127 @@
+---
+title: "wsdiff: Responsive diffs in plain HTML"
+date: 2025-07-25T23:42:00+01:00
+summary: >
+ There's many tools that render diffs on the web, but almost none that work well on small screens such as phones. I
+ fixed this by publishing wsdiff, a diffing tool written in Python that produces diffs as beautiful, responsive,
+ static, self-contained HTML pages. wsdiffs wrap text to fit the window, and dynamically switch between unified and
+ split diffs based on screen size using only CSS.
+---
+
+Demo
+----
+
+First off, have a demo. Because of the width of this page, the output will show an unified diff. To try out the split
+diff layout, make sure your browser window is wide enough and open the demo in a separate tab using `this link
+`__.
+
+wsdiff supports dark mode, try it out by toggling dark mode in your operating system!
+
+.. raw:: html
+
+
+
+Core Features
+-------------
+
+There's many tools that render diffs on the web, but almost none that work well on small screens such as phones. I fixed
+this by publishing `wsdiff `__, a diffing tool written in Python that produces diffs
+as beautiful, responsive, static, self-contained HTML pages. wsdiffs wrap text to fit the window, and dynamically switch
+between unified and split diffs based on screen size using only CSS.
+
+Responsive Line Wrapping
+........................
+
+The first challenge I solved was wrapping source code lines to match the available screen space. Other tools often just
+show horizontal scroll bars, which is an okay workaround when you're mostly working with hard-wrapped source code on a
+laptop or desktop screen, but which results in catastrophic UX on any phone.
+
+I solved line breaking with a combination of CSS-controlled, web-standard word breaking rules: ``overflow-wrap:
+anywhere`` for source code (`MDN link `__) and
+``white-space: pre-wrap`` to preserve whitespace accurately (`MDN link
+`__). To make both sides of the split diff align, and to
+align line numbers with wrapped source code lines, the diff is laid out using a `CSS grid layout`_. In side-by-side
+view, the layout has four columns: two for line numbers and two for the content. In unified view, the left ("old")
+content column is dropped, and the deleted or modified lines that are highlighted in it in side-by-side view are slotted
+into the remaining right column.
+
+When soft-wrapping source code, text editors will often display a little curved arrow marker to indicate that a line was
+soft-wrapped, and that there is not actually a newline character in the file at that location. wsdiff solves this
+using the same technique I used for the soft-wrapping code blocks in this blog, described `here <{{}}>`__. It inserts a string of ``"\a↳\a↳\a↳\a↳\a↳..."`` into the line number
+element's ``::after`` pseudo-element. This string evaluates to a sequence of unicode arrows separated by line breaks,
+and starting with an empty line. The ``::after`` pseudo-element is positioned using ``position: absolute``, and the
+parent ```` has ``position: relative`` set. This way, the arrow pseudo-element gets placed on top
+of the lineno span without affecting the layout at all. By setting ``overflow: clip`` on the parent ````, the arrow pseudo-element gets cut off vertically wherever the parent line number element naturally
+ends. Since both the line and the line number element share a grid row, the line number element always matches the
+height of the soft-wrapped line.
+
+Responsive Split/Unified Layout Selection
+.........................................
+
+To dynamically change between unified and side-by-side views, wsdiff uses a web-standard `Media Query`_. By default, the
+page is laid out for side-by-side view. In the HTML source, the diff is listed as it is displayed in side-by-side view,
+with the old and new lines along with their line numbers interleaved.
+
+The magic happens when the media query gets triggered by a narrow screen width. The media query re-adjusts the layout in
+four core steps:
+
+ 1. All unchanged lines in the left (old) column are hidden.
+ 2. The left content column of the grid layout is hidden, so that now there are three columns: old line number, new line
+ number, and unified content.
+ 3. All deleted or changed lines from the left (old) column are re-located to the right column. They naturally slot in
+ in the right spot because they already appear in the right order in the HTML source.
+ 4. By slotting in the old lines in the right column, we have created gaps in the line number columns. Every deleted
+ line has an empty cell in the new line number column, and every inserted line has one in the old line number column.
+ The CSS adjusts the layout of these empty cells such that the border lines align nicely, and it overrides the
+ newline markers so that they only show in the right (new) line number column, not both.
+
+Since this is all CSS, it happens automatically and near-instantly. Since it is using only web standard features, it
+works across browsers and platforms.
+
+Unchanged Line Folding in CSS
+.............................
+
+When showing the diff of a large file, it is usually best to hide large runs of unchanged lines. wsdiff does this
+similar to code folding in text editors. When a long run of unchanged lines is detected, a marker is placed spanning the
+diff. This marker contains a checkbox that can be toggled to hide the unchanged lines. This feature is done completely
+in CSS using a ``:has(input[type="checkbox"]:checked)`` selector.
+
+The actual mechanics are quite simple. To cleanly hide the lines, they must be placed in a container ``
``. That div
+has a CSS subgrid layout using ``display: grid; grid-template-columns: subgrid;``, meaning that its contents align to
+the surrounding diff grid.
+
+Dark Mode
+.........
+
+Integrating a website with the OS-level dark mode is surprisingly easy. All you need is a `Media Query`_ that selects
+for ``@media (prefers-color-scheme: dark)`` and you're good. wsdiff uses named colors using `CSS Custom Properties`_, so
+the actual dark mode media query only needs to override these color properties, and the rest of the CSS will be
+re-computed automatically.
+
+Limitations: Text selection
+...........................
+
+A limitation in having a combined, single HTML source for both side-by-side and unified diffs is that text selection
+only works naturally in either mode. You can't make text selection work in both simultaneously without re-sorting the
+lines in the HTML source, since there is no way to override the text selection order from pure CSS. In wsdiff, I worked
+around this issue by just disabling text selection on the unchanged lines in the left (old) column, so selecting text in
+the right column copies the unified diff as one would expect.
+
+Try it yourself!
+----------------
+
+You can find the demo from above at `this link `__.
+
+You can install wsdiff yourself `from PyPI `__:
+
+.. code:: sh
+
+ $ pip install -U wsdiff
+ Successfully installed wsdiff-0.3.1
+ $ wsdiff old.py new.py -o diff.html
+
+.. _`CSS grid layout`: https://css-tricks.com/snippets/css/complete-guide-grid/
+.. _`Media Query`: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries
+.. _`CSS Custom Properties`: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties
diff --git a/static/wsdiff-example.html b/static/wsdiff-example.html
new file mode 100644
index 0000000..59f6ceb
--- /dev/null
+++ b/static/wsdiff-example.html
@@ -0,0 +1,889 @@
+
+
+
+
+ diff: example_old.py / example.py
+
+
+
+
+
+
+
+
+
+6
+6
+7importclick
+7importclick
+8fromreedmullerimportreedmuller
+8fromreedmullerimportreedmuller
+9
+9
+10
+10
+11classTag:
+11classTag:
+12""" Helper class to ease creation of SVG. All API functions that create SVG allow you to substitute this with your
+12""" Helper class to ease creation of SVG. All API functions that create SVG allow you to substitute this with your
+13 own implementation by passing a ``tag`` parameter. """
+13 own implementation by passing a ``tag`` parameter. """
+14
+14
+15def__init__(self,name,children=None,root=False,**attrs):
+15def__init__(self,name,children=None,root=False,**attrs):
+16if(fill:=attrs.get('fill'))andisinstance(fill,tuple):
+16if(fill:=attrs.get('fill'))andisinstance(fill,tuple):
+17attrs['fill'],attrs['fill-opacity']=fill
+17attrs['fill'],attrs['fill-opacity']=fill
+18if(stroke:=attrs.get('stroke'))andisinstance(stroke,tuple):
+18if(stroke:=attrs.get('stroke'))andisinstance(stroke,tuple):
+19attrs['stroke'],attrs['stroke-opacity']=stroke
+19attrs['stroke'],attrs['stroke-opacity']=stroke
+20self.name,self.attrs=name,attrs
+20self.name,self.attrs=name,attrs
+21self.children=childrenor[]
+21self.children=childrenor[]
+22self.root=root
+22self.root=root
+23
+23
+24def__str__(self):
+24def__str__(self):
+25prefix='<?xml version="1.0" encoding="utf-8"?>\n'ifself.rootelse''
+25prefix='<?xml version="1.0" encoding="utf-8"?>\n'ifself.rootelse''
+26opening=''.join([self.name]+[f'{key.replace("__",":").replace("_","-")}="{value}"'forkey,valueinself.attrs.items()])
+26opening=''.join([self.name]+[f'{key.replace("__",":").replace("_","-")}="{value}"'forkey,valueinself.attrs.items()])
+27ifself.children:
+27ifself.children:
+28children='\n'.join(textwrap.indent(str(c),'')forcinself.children)
+28children='\n'.join(textwrap.indent(str(c),'')forcinself.children)
+29returnf'{prefix}<{opening}>\n{children}\n</{self.name}>'
+29returnf'{prefix}<{opening}>\n{children}\n</{self.name}>'
+
+56**namespaces,
+60**namespaces,
+57root=True)
+61root=True)
+58
+62
+59
+63
+60@click.command()
+64@click.command()
+61@click.option('-h','--height',type=float,default=20,help='Bar height in mm')
+65@click.option('-h','--height',type=float,default=20,help='Bar height in mm')
+62@click.option('-t/-n','--text/--no-text',default=True,help='Whether to add text containing the data under the bar code')
+66@click.option('-t/-n','--text/--no-text',default=True,help='Whether to add text containing the data under the bar code')
+63@click.option('-f','--font',default='sans-serif',help='Font for the text underneath the bar code')
+67@click.option('-f','--font',default='sans-serif',help='Font for the text underneath the bar code')
+64@click.option('-s','--font-size',type=float,default=12,help='Font size for the text underneath the bar code in points (pt)')
+68@click.option('-s','--font-size',type=float,default=12,help='Font size for the text underneath the bar code in points (pt)')
+65@click.option('-b','--bar-width',type=float,default=1.0,help='Bar width in mm')
+69@click.option('-b','--bar-width',type=float,default=1.0,help='Bar width in mm')
+66@click.option('-m','--margin',type=float,default=3.0,help='Margin around bar code in mm')
+70@click.option('-m','--margin',type=float,default=3.0,help='Margin around bar code in mm')
+67@click.option('-c','--color',default='black',help='SVG color for the bar code')
+71@click.option('-c','--color',default='black',help='SVG color for the bar code')
+68@click.option('--text-color',default=None,help='SVG color for the text (defaults to the bar code\'s color)')
+72@click.option('--text-color',default=None,help='SVG color for the text (defaults to the bar code\'s color)')
+69@click.option('--dpi',type=float,default=96,help='DPI value to assume for internal SVG unit conversions')
+73@click.option('--dpi',type=float,default=96,help='DPI value to assume for internal SVG unit conversions')
+70@click.argument('data')
+74@click.argument('data')
+71@click.argument('outfile',type=click.File('w'),default='-')
+75@click.argument('outfile',type=click.File('w'),default='-')
+72defcli(data,outfile,height,text,font,font_size,bar_width,margin,color,text_color,dpi):
+76defcli(data,outfile,height,text,font,font_size,bar_width,margin,color,text_color,dpi):
+73data=int(data,16)
+77data=int(data,16)
+74text_color=text_colororcolor
+78text_color=text_colororcolor
+75
+79
+76NUM_BITS=26
+80NUM_BITS=26
+77
+81
+78data_bits=[bool(data&(1<<i))foriinrange(NUM_BITS)]
+82data_bits=[bool(data&(1<<i))foriinrange(NUM_BITS)]
+79data_encoded=itertools.chain(*[
+83data_encoded=itertools.chain(*[
+80(a,nota)foraindata_bits
+84(a,nota)foraindata_bits
+81])
+85])
+82data_encoded=[True,False,True,False,*data_encoded,False,True,True,False,True]
+86data_encoded=[True,False,True,False,*data_encoded,False,True,True,False,True]
+83
+87
+84width=len(data_encoded)*bar_width
+88width=len(data_encoded)*bar_width
+85# 1 px = 0.75 pt
+89# 1 px = 0.75 pt
+86pt_to_mm=lambdapt:pt/0.75/dpi*25.4
+90pt_to_mm=lambdapt:pt/0.75/dpi*25.4
+87font_size=pt_to_mm(font_size)
+91font_size=pt_to_mm(font_size)
+88total_height=height+font_size*2
+92total_height=height+font_size*2
+89
+93
+90tags=[]
+94tags=[]
+91forkey,groupinitertools.groupby(enumerate(data_encoded),key=lambdax:x[1]):
+95forkey,groupinitertools.groupby(enumerate(data_encoded),key=lambdax:x[1]):
+92ifkey:
+96ifkey:
+93group=list(group)
+97group=list(group)
+94x0,_key=group[0]
+98x0,_key=group[0]
+95w=len(group)
+99w=len(group)
+96tags.append(Tag('path',stroke=color,stroke_width=w,d=f'M {(x0+w/2)*bar_width} 0 l 0 {height}'))
+100tags.append(Tag('path',stroke=color,stroke_width=w,d=f'M {(x0+w/2)*bar_width} 0 l 0 {height}'))
+97
+101
+98iftext:
+102iftext:
+99tags.append(Tag('text',children=[f'{data:07x}'],
+103tags.append(Tag('text',children=[f'{data:07x}'],
+100x=width/2,y=height+0.5*font_size,
+104x=width/2,y=height+0.5*font_size,
+101font_family=font,font_size=f'{font_size:.3f}px',
+105font_family=font,font_size=f'{font_size:.3f}px',
+102text_anchor='middle',dominant_baseline='hanging',
+106text_anchor='middle',dominant_baseline='hanging',
+103fill=text_color))
+107fill=text_color))
+104
+108
+