1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
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-example.html>`__.
wsdiff supports dark mode, try it out by toggling dark mode in your operating system!
.. raw:: html
<iframe src="/wsdiff-example.html" style="width: 100%; height: 30em; border: 1px #d0d0d0 solid" id="wsdiff example diff"></iframe>
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 <https://pypi.org/project/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 <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-wrap>`__) and
``white-space: pre-wrap`` to preserve whitespace accurately (`MDN link
<https://developer.mozilla.org/en-US/docs/Web/CSS/white-space>`__). 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 <{{<ref
"blog/css-only-code-blocks/index.rst">}}>`__. 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 ``<span class="lineno">`` 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 ``<span
class="lineno">``, 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 ``<div>``. 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 </wsdiff-example.html>`__.
You can install wsdiff yourself `from PyPI <https://pypi.org/project/wsdiff/>`__:
.. 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
|