Building a software recommendations table exposed three non-obvious CSS problems: a description column that stayed narrow no matter what you told it, a Content Security Policy that silently blocked every fix, and a scroll container that required a one-liner to make the first column sticky. This brief walks through each problem and its solution.
The Goal
A wide reference table – Name, Description, Replaces, AI, Privacy, Censorship Resistance, Open Source, Twitter, Website, Repository – that:
- Displays description text on one or two lines rather than five or six
- Scrolls horizontally without losing track of which row you are on
- Works without a server-side framework or build step
Problem 1: Column Width Rules Were Ignored
The first attempt was a CSS class rule on the description cell:
.recs td:nth-child(2) { min-width: 32em; }It had no effect. The description column stayed at roughly 140px regardless of the value. The reason is that table-layout: auto – the browser default – ignores min-width and width on td elements entirely. It distributes column space based on content and the table’s own width, not on CSS size hints applied to cells.
The correct approach is table-layout: fixed. With this mode, the browser uses the widths declared on the first row’s cells to set column widths, and it respects them.
table { table-layout: fixed; width: 82em; }With explicit widths on the th elements in the header row:
<th style="width:10em;">Name</th>
<th style="width:36em;">Description</th>
<th style="width:8em;">Twitter</th>
...The description column is now 36em wide regardless of content. The table’s total declared width (sum of all column widths) drives how wide the table actually renders.
Problem 2: The Content Security Policy Blocked Every Fix
Even after switching to table-layout: fixed, nothing changed in the browser. Adding !important, switching to inline style="" attributes, reloading the page – none of it worked. The description column stubbornly stayed at 140px.
The culprit was the Content Security Policy meta tag already present on the page:
<meta http-equiv="Content-Security-Policy"
content="... style-src 'self' https://archerships.com; ...">style-src without 'unsafe-inline' tells the browser to reject:
<style>blocks embedded in the HTMLstyle=""attributes on individual elements
Both sources are considered “inline” styles. Only stylesheets fetched from https://archerships.com were allowed. So the external simple.min.css loaded fine, but every local CSS rule we added was silently discarded before the browser even tried to apply it.
The fix was to add 'unsafe-inline' to style-src:
style-src 'self' https://archerships.com 'unsafe-inline';The word “unsafe” in the directive name is a warning that inline styles can be exploited if the page ever renders untrusted user input as HTML. For a static page with no user-generated content, the risk is not present and 'unsafe-inline' is the right call.
A stricter alternative is a CSP nonce: a random token added to each <style> tag and listed in the Content-Security-Policy header. This allows inline styles from your own HTML while blocking injected ones. It requires server-side generation of the nonce on each request and is overkill for a static site.
Problem 3: The Table Was Wider Than the Page
With table-layout: fixed and a total width of 114em across ten columns, the table overflows the page’s content column. simple.min.css constrains body > * to max-width: 70ch, which is roughly 560–700px depending on font metrics. The table is over 1800px wide.
The fix is a scroll wrapper:
<div style="overflow-x: auto;">
<table class="recs" style="table-layout: fixed; width: 114em;">
...
</table>
</div>overflow-x: auto tells the browser to show a horizontal scrollbar when the child element (the table) is wider than the container. The table renders at its full declared width; the wrapper clips and scrolls it.
Problem 4: Scrolling Lost Context
With horizontal scrolling working, a new problem appeared: scrolling right caused the Name column to disappear off the left edge, making it impossible to tell which row you were reading.
The fix is one CSS rule:
.recs th:first-child,
.recs td:first-child {
position: sticky;
left: 0;
z-index: 1;
background: var(--bg, #111110);
}position: sticky; left: 0 pins the element to the left edge of its scroll container as soon as it would otherwise scroll out of view. It behaves like position: relative until the scroll threshold is reached, then locks in place.
The background declaration is required. Without it, the sticky cell is transparent, and the columns scrolling behind it show through. Setting it to the page’s background color (var(--bg) from the site’s CSS variables) makes the sticky cell visually opaque.
z-index: 1 ensures the sticky cell renders on top of any content scrolling past it.
The Redundant Category Column
The original table had a Category column (AI, Android, Browser, etc.) as its first column. Every section already had an <h2> heading naming the category, making the column redundant. Removing it via a Python script freed roughly 10em of width for the remaining columns, which was more useful than any single-rule CSS fix.
import re
with open('recommendations.html') as f:
html = f.read()
# Remove Category header
html = re.sub(r'<th onclick="sortTable\(this\)">Category</th>\s*', '', html)
# Remove first <td> (category data) from each row
html = re.sub(r'(<tr>)<td>[^<]*</td>', r'\1', html)Summary
| Problem | Wrong Approach | Correct Fix |
|---|---|---|
| Description column too narrow | min-width on td |
table-layout: fixed + width on th |
| CSS rules ignored | Adding !important |
Add 'unsafe-inline' to style-src CSP |
| Table wider than page | Shrink column widths | overflow-x: auto scroll wrapper |
| Name scrolls off-screen | position: sticky; left: 0 + opaque background |
Want to stay in touch?
- Join my Signal announce-only group to be notified when I have a new essay up and other important announcements.
- For discussion, join the libertygardeners Signal group.
- Subscribe to my mailing list.
- Email: [email protected]
- Signal: archerships.43
- Website: archerships.com
- Other social media: Substack | Twitter | Facebook | Nostr | Odysee
If you’d like to support my work:
- Share my posts.
- Become a subscriber to my newsletter.
- Attend my live events (dinner parties, conferences, pop-up cities, etc).
- Introduce me to like-minded people.
- Make a one-time donation to support my work: Crypto | Fiat
- Hire me for privacy / crypto / censorship consulting.
If there is a topic you’d like me to cover, please let me know!
Questions, comments, and suggestions are welcome.