
Product Thinking (3): UX & Design Systems — Tokens, Dark Mode, and Bilingual
Building a design system from CSS tokens to dark mode to bilingual content — and why consistency matters more than creativity.
The Problem With “Just Make It Look Good”#
Every engineer who has ever touched CSS knows the feeling: you open a stylesheet written six months ago and find forty-seven shades of grey, twelve font sizes that follow no discernible scale, and a dark mode implementation consisting of filter: invert(1) on the body element. The code works. The site renders. But every new feature requires archaeology — digging through layers of ad-hoc decisions to figure out what the “right” shade of muted text is supposed to be.

I have built two products that forced me to confront this problem at different scales. One is chenk.top, a bilingual technical blog with 738 articles across English and Chinese, serving readers who care about typography and long-form readability. The other is AI4Marketing, a SaaS platform with nine supported languages, dozens of generation workflows, and a UI that must work equally well on a phone in Bangkok and a widescreen monitor in San Francisco.
Both taught me the same lesson: design systems are not about expressing creativity. They are about saying no — consistently, systematically, and with enough conviction that future-you cannot easily override the constraint. This essay traces that conviction through five problems: token architecture, dark mode, bilingual content, responsive layout, and the philosophy of constraint itself.
Token-Driven Design: One Source of Truth#
The Problem#
When I started chenk.top in its current form, I had a typical CSS situation: colors defined inline, font sizes chosen by feel, spacing that looked right on my 14-inch MacBook but broke everywhere else. Adding a new article series meant copying hex codes from an existing one and tweaking the hue by hand. Dark mode was bolted on with @media queries overriding half the stylesheet.
The issue was not aesthetic — it was architectural. Without a single source of truth, every design decision is local. Local decisions compound into inconsistency.
The Principle: Semantic Tokens as API#
I adopted an approach stripped of Material Design’s corporate verbosity: a flat set of CSS custom properties in tokens.css, serving as the design system’s public API. Every downstream consumer — components, pages, dark mode — references tokens, never raw values. The token file has three layers.
Primitive layer — raw palette and scale values:
| |
Five accent hues. Not four, not seven — five, because that is enough to distinguish every content series while remaining memorable. The mapping is not random: it reflects how I mentally categorize content. Red for reinforcement learning and systems, indigo for AI platforms and time series, emerald for recommendation and cloud infrastructure, amber for classical algorithms and differential equations, violet for NLP and linear algebra. When I assign a new series, I am not picking a color — I am locating the series in a taxonomy.
Scale layer — a modular type scale at a major-third ratio (1.250):
| |
No pixel values. No magic numbers. Every size is a step on the scale. If I need something between --fs-lg and --fs-xl, the answer is: I do not need it. The constraint exists to prevent the “just 2px bigger” drift that destroys visual rhythm over time.
Semantic layer — colors named by function, not appearance:
| |
Notice --paper and --ink, not --white and --black. This is not aesthetics — it is a contract. When dark mode activates, --paper becomes #0f1117 and --ink becomes #ecedef. Every component referencing --paper adapts automatically. A component referencing --white needs a manual override — and those overrides are how design systems accrue debt.
The Result#
Adding a new content series takes exactly one decision: which of the five hues to assign. Everything else — the card gradient, the hover accent, the TOC indicator — derives automatically. The token file is 85 lines. The entire site’s visual identity lives in those 85 lines.
For AI4Marketing, the same principle scales differently. The platform uses Tailwind CSS with CSS custom properties beneath, and a ThemeProvider that writes data-theme to the document root. The token vocabulary shifts — --bg-primary, --text-primary, --accent, --border — but the architecture is identical: semantic names, single source of truth, zero raw values in component code. Adding a dark-mode-aware payment card to the platform takes no dark-mode-specific work; it inherits automatically.
Dark Mode Done Right#
The Problem#
The naive dark mode approach is color inversion: take the light palette, flip the lightness channel, call it done. This produces interfaces that feel hostile — washed-out accents on backgrounds that are either too dark (pure black causes eye strain) or too light (grey that reads as a loading error). Gradients that worked beautifully on cream become muddy when the backdrop goes dark. Shadows become invisible or produce an uncanny halo.
The subtler failure is reusing the same saturation levels across modes. A muted #c0392b on cream carries visual weight. That exact value on #0f1117 disappears.
The Principle: Dark Mode Is a New Palette, Not a Transform#
My dark.css does not invert — it re-authors. Every semantic token gets a considered dark equivalent:
| |
The background is #0f1117 — a deep blue-grey that reads as dark without the harshness of pure black. The accent hues shift deliberately upward in brightness and saturation because dark backgrounds demand more luminance to achieve equivalent visual weight:
| |
These are not computed transforms — each shift was evaluated by eye against the dark paper. A formula would produce mathematically correct values that look wrong; per-hue decisions produce values that look right.
The gradients are re-authored to match:
| |
The most distinctive decision is ambient lighting. The dark body background is not a flat color — it carries three radial gradients simulating colored light sources:
| |
Indigo top-left, violet top-right, emerald bottom-center — all at 6-10% opacity. The effect is barely conscious: the page feels alive and dimensional rather than flat. On hover, series cards in dark mode emit a colored glow matched to their assigned hue:
| |
The toggle itself prefers prefers-color-scheme as the initial state, then accepts manual override persisted to localStorage. The system updates automatically at 06:00 and 18:00 for AI4Marketing, matching typical screen usage patterns. Neither feature requires adding anything to individual components.
The Result#
Dark mode is a different experience of the same content, not a lazy adaptation. Readers who visit at night encounter a space that feels designed for low-light reading. The per-hue saturation shift means content series retain their visual identity in both modes — an indigo-accented time-series article looks distinctively indigo in both light and dark, just differently calibrated.
The Bilingual Challenge#
The Problem#
chenk.top maintains 738 articles mirrored across English and Chinese. These are not machine translations — each version is written natively for its audience. The English version uses a direct technical voice. The Chinese version assumes shared cultural context and uses domain-specific Chinese terminology correctly — not the stilted output of MT systems.
The challenge is structural, not linguistic. How do you maintain 738 parallel document pairs, keep them in sync when one is updated, provide seamless language switching, and handle SEO correctly for both variants?
The Principle: translationKey as the Binding Contract#
Hugo’s multilingual system uses content directories (content/en/ and content/zh/) with a translationKey in frontmatter to bind corresponding articles:
| |
The key is a semantic identifier, not a filename. This decouples the URL slug (which can differ between languages for SEO) from the binding relationship. Each language gets its own slug while sharing a logical identity. This matters: a Chinese URL containing a transliterated English technical term performs worse in Baidu search than one using idiomatic Chinese phrasing for the same concept.
One early gotcha: Hugo’s template conditional for language detection uses eq .Language.Lang "zh", not "zh-cn". The lang key in frontmatter must match what Hugo configures in config.yaml. Getting this wrong silently breaks the language switcher — the button appears but navigates incorrectly because the binding fails.
At the template level, hreflang tags in <head> declare the relationship to search engines:
| |
The language switcher is deliberately simple: a text toggle in the header that navigates to the translated version of the current page, not to the other language’s homepage. If no translation exists, the switcher hides. Never offer a broken promise — a link that drops the reader on an unrelated page destroys trust faster than no link at all.
Scaling to Nine Languages: AI4Marketing#
AI4Marketing takes bilingual further — nine languages (zh-CN, en-US, ja-JP, ko-KR, es-ES, th-TH, vi-VN, pt-BR, ar-SA). The philosophy differs from chenk.top: this is AI-assisted localization, not human-written parallel content.
The key insight in the localization module is the distinction between translation and localization:
“Your task is NOT translation — it is LOCALIZATION: adapt the content so it reads as if a native marketer wrote it for the target market.”
The system uses Qwen to adapt marketing content with per-format prompts. Each format has different localization priorities: social notes need adapted hashtag conventions for the target platform (LINE tags in Thailand, Naver Blog conventions in Korea); video scripts need pacing adjusted for the target language’s syllable density (Japanese and Korean pack more semantic content per breath group than Spanish or Portuguese); articles need localized SEO keywords, not literal translations of English search terms.
Table-structured content required a defensive fix. When Qwen localizes a Markdown table, it occasionally misreads the pipe structure and merges columns. The solution is an assertion before writing: verify len(localized_rows) == len(source_rows) and that each row has the expected column count. A structural mismatch triggers a retry with an explicit schema reminder, not silent corruption.
The UX surfaces all of this as a single “Localize” action — reducing the mental model from “I need to translate this eight times” to “I can reach eight markets.” Progressive disclosure handles the rest: clicking “Localize” shows a single button; expanding the panel reveals per-language toggles for users who want finer control.
The Result#
On chenk.top, 36 content directories per language, with translationKey ensuring every article has its counterpart. The audit pipeline (scripts/audit/) catches orphaned translations and residual English fragments in Chinese posts. On AI4Marketing, localization is a first-class pipeline stage, not an afterthought — it generates in parallel with the primary content, adding roughly two seconds of latency to produce eight additional market-ready variants.
Responsive Without Breakpoint Hell#
The Problem#
The traditional responsive approach creates a matrix of breakpoints and component states that grows combinatorially. A card component might have mobile, tablet, and desktop variants; multiply across twenty component types and you have sixty layout states to maintain. Later @media queries override earlier ones in unexpected ways, specificity wars escalate, and mobile testing becomes whack-a-mole.
The Principle: Additive Mobile Fixes in a Dedicated File#
My approach inverts the methodology. The base CSS is designed for desktop readability. Mobile adaptation lives entirely in mobile-fixes.css — additive patches against specific rendering issues, not layout rewrites.
The file opens with its own scope declaration:
| |
It is an audit pass, not a design pass. The logic:
1. Global overflow guard first. overflow-x: hidden on html/body plus min-width: 0 on every flex/grid child containing article content. This single pairing prevents 80% of mobile layout bugs — elements bursting the viewport because their flex children refuse to shrink below intrinsic width.
2. Code blocks are the primary challenge. Hugo’s syntax highlighter emits table-based markup when line numbers are enabled: div.highlight > div.chroma > table.lntable, with td.lntd:first-child holding line numbers and td.lntd:last-child holding the code. The table structure resists normal overflow containment. The fix converts the table to a flex row:
| |
Line numbers become a fixed-width flex item; the code column becomes a shrinkable, scrollable flex item. The line numbers stay pinned left while the code scrolls underneath — the correct reading experience for long lines.
3. KaTeX display blocks. On narrow screens, display-mode math (\displaystyle fractions, aligned environments) overflows its container. The fix is two rules:
| |
The outer rule gives display math a scroll container. The media query trims the font on mobile so common display expressions fit without scrolling. On an iPhone SE (320px logical width), this prevents horizontal page scroll for most equations while keeping them legible.
4. Drawer transform overflow. The mobile navigation drawer uses a CSS transform for its slide-in animation. An early version caused persistent horizontal overflow — the drawer’s off-screen initial position leaked into the body’s scroll width. The fix is two properties working together:
| |
overflow-x: hidden on body breaks position: fixed children (the header, search overlay). overflow-x: clip contains the overflow without creating a new stacking context, so fixed descendants remain unaffected.
5. Progressive size reduction, not layout change. On small phones, the hero title adjusts from clamp(2.4rem, 6.4vw, 4.6rem) to an explicit 1.7rem. The layout does not change; only the scale adjusts. This keeps the template logic simple — one layout, tuned per viewport size.
The mobile-fixes file is 438 lines. That is substantial, but every line addresses a specific mobile rendering issue. No layout logic, no component definitions. I can audit all mobile behavior by reading one file rather than hunting through scattered @media blocks across twenty stylesheets.
Code block copy button incident. During the mobile audit I found a second “copy” button appearing on some code blocks. The JavaScript selector was .highlight, .highlight pre — both the outer wrapper and the inner pre were receiving the button. The fix: use el.closest(".highlight") as the deduplication anchor. Only one button per .highlight wrapper, regardless of how many nested pre elements exist.
The Result#
The site renders correctly on every device I have tested — iPhone SE (320px) through 27-inch iMac (2560px) — with a single layout architecture. Mobile fixes accommodate the desktop design rather than fighting it. Adding a new component requires answering one question: does it overflow at 320px? If yes, one rule in mobile-fixes.css. If no, nothing to do.
Design as Constraint#
The Button Rule#
In AI4Marketing’s UI, one rule governs every action row: one filled primary button, all others outlined. This is not a guideline — it is a hard constraint enforced through component design.
The reasoning is cognitive. When a user sees multiple filled buttons, they must read each label to determine which action to take. One filled button makes the primary action identifiable at the pre-attentive level — before conscious reading begins. Outlined secondary buttons remain discoverable without competing for attention.
The implementation cascades into specifics:
- The primary button uses
var(--accent), never a hardcoded color - Secondary buttons use
border: 1px solid var(--border)with a transparent background - Dark mode primary buttons must still use theme variables — hardcoded grays that work on a light background look wrong or disappear entirely on
#0f1117 - Destructive actions (delete, cancel subscription) use a red variant derived from
--hue-0, but the one-primary rule still holds — the red button is the only filled button in its row
The failure mode that triggered this rule: an early version of the payment card had both “Upgrade” and “Contact Sales” as filled buttons in the same row. User testing showed people repeatedly clicking “Contact Sales” first, interpreting it as the primary action because both buttons had equal visual weight. Switching to filled/outlined resolved it without changing any copy.
The “No Emoji” Rule#
chenk.top does not use emoji in headings, navigation, or body text. This is deliberate resistance to a trend in “friendly” technical writing.
Emoji are culturally loaded, render inconsistently across platforms (sometimes jarringly), break the typographic rhythm of a serif heading, and add noise without information. A heading that says “## Performance Optimization” communicates identically to one with a sparkle prepended — the emoji adds visual filtering work for the reader.
This extends to AI-generated content: when Claude produces output for the blog, the system instruction is explicit — no emoji unless the user specifically requests them. The constraint protects voice consistency. If I allow emoji in AI output, I will eventually get emoji in headings, and the accumulated exceptions will erode the system faster than any intentional design change.
The Font Stack#
| |
Using Source Serif Pro for headings in a technical blog is deliberately counter-trend. Most developer blogs use geometric sans-serifs (Inter, Geist, Space Grotesk) for everything. I chose serif headings for two reasons that compound.
First, typeface contrast creates visual hierarchy more efficiently than size alone. Body text is --font-sans (Inter). A serif H2 at the same pixel size as a sans H2 reads as more important because of the formality shift — which means I need fewer size steps to establish hierarchy. The type scale can stay compact.
Second, the site aspires to long-form readability, not documentation density. Serif headings evoke editorial design — The Atlantic, Stripe’s writing — rather than API reference pages. The Chinese fallback (Songti SC, STSong) carries this through: song-style typefaces are the CJK equivalent of serif, maintaining the editorial register in Chinese text.
JetBrains Mono is used exclusively in code blocks and keyboard indicators. Never in body text, never in UI labels. The exclusivity is what makes it work — readers recognize “monospace here” as a consistent signal for “this is code or a system literal.”
Progressive Disclosure: Showing Less to Enable More#
Search Overlay#
The search function is invisible until invoked. There is no persistent search bar consuming header space. Pressing / (or Cmd+K) opens a full-screen overlay with a focused input, backdrop blur, and instant results:
| |
Results appear as the user types — no submit button, no separate results page. The interaction model is Spotlight/Alfred: invoke, type, select, dismiss. Escape or clicking the backdrop closes it. The search overlay works in both light and dark mode without additional styling because it references --paper and --ink throughout.
Drawer Navigation#
On mobile, navigation transforms from a horizontal header to a side drawer. The drawer contains identical links to the desktop nav plus a language switcher, in a touch-optimized layout:
| |
Twelve pixels of vertical padding produces a 44px+ touch target — Apple’s Human Interface Guidelines minimum. The drawer itself carries padding: 20px 22px, ensuring no content touches the viewport edge. The visibility/clip technique described in the responsive section keeps this drawer from producing horizontal overflow.
Wizard vs. Form#
AI4Marketing’s generation features use a progressive disclosure wizard rather than a single long form. The wizard surfaces one decision at a time: select content type, then fill content details, then choose localization targets. Each step validates before advancing, so errors are caught close to where they occur rather than as a bulk error list at submission.
The visual language reinforces progression: a stepper component shows completed steps with a checkmark, the current step highlighted with var(--accent), and future steps in --ink-faint. The stepper is not purely decorative — it also serves as navigation, letting users jump back to a completed step to revise without losing later work.
This reduced the form abandonment rate compared to the original single-form layout. Users who saw the long form scrolled to evaluate total effort before starting; the wizard makes the entry cost feel lower because you only see the current step.
Keyboard Shortcuts Panel#
For power users, a floating action button (bottom-right, 36px circle, semi-transparent) reveals a keyboard shortcuts reference. The keys use physical rendering:
| |
Beveled edges, bottom shadow simulating depth, a subtle press animation on hover. The micro-interaction rewards discovery — users who find the shortcut panel get a well-rendered reference, not a plain text list. On mobile (under 720px), the fab hides entirely. Touch users have no keyboard; the affordance is irrelevant; no viewport space is wasted.
The Lesson: Design Systems Are About Saying No#
Every design decision described above is fundamentally a constraint. Five hues, not unlimited. One primary button per row. No emoji, ever. Serif headings in one place, nowhere else. Mobile fixes in one file. These feel limiting in the moment.
The pressure is always to create exceptions. “This card would look great with a sixth hue.” “This dialog really needs two primary actions.” “This heading would be friendlier with a sparkle.” The answer is always no — not because the individual exception would look bad (it would probably look fine), but because each exception erodes the system. One extra hue becomes three. One extra primary button becomes a pattern copied across the product. One emoji becomes a dozen, and the blog starts looking like a Notion template.
The power of a design system is not what it enables — CSS can enable anything. The power is what it prevents. When I style a new component, I do not make design decisions. I reference tokens. The decision happened once, in one place, and propagates everywhere. The token layer absorbs all creative decisions; downstream code is mechanical.
In practice, this manifests as speed. Adding dark mode to a new component takes zero effort — it inherits because it uses semantic tokens. Adding a ninth language to AI4Marketing requires one database entry and one prompt configuration, not a UI redesign. Making a page mobile-responsive means checking one file, not auditing the whole layout. The formula: invest heavily in the constraint layer once, spend near-zero on every downstream decision. Design systems that work are boring to use. That is exactly the point.
Next in this series: the build tooling and deployment pipeline that makes all of this ship reliably — from Hugo’s sub-second builds to PM2 zero-downtime restarts.
This is Part 3 of Product Thinking (5 parts in total). Previous: Part 2 — Security Engineering · Next: Part 4 — Self-Healing Systems
Product Thinking 5 parts
- 01 Product Thinking (1): Architecture Design — From Monolith to Autonomous Agents
- 02 Product Thinking (2): Security Engineering — Defense Without Paranoia
- 03 Product Thinking (3): UX & Design Systems — Tokens, Dark Mode, and Bilingual you are here
- 04 Product Thinking (4): Self-Healing Systems — Teaching Machines to Fix Themselves
- 05 Product Thinking (5): Abstraction Thinking — From Math to Systems