Light Mode โหมดสว่าง
The default bright scheme, the same tokens with light values, plus a contrast check of every token pair.
Live demo
Everything here talks to the real Spring Boot + SQL Server back end.Light or dark
Contrast check (WCAG 2.1)
Measured in the browser from the computed colours. Body text needs 4.5 : 1 (AA), large text 3 : 1.
| Sample | Text token | Background token | Ratio | Result |
|---|
Light tokens
What to notice
- Light and dark share one component stylesheet; only the token values differ.
- Shadows are stronger in light mode and replaced by borders in dark mode.
- The contrast table measures text against its background in the browser.
Try it
Source code
11 parts, read from the files in this project
src/main/java/com/study/uiux/config/ClientIdFilter.java
Anonymous client id cookie
@Component
public class ClientIdFilter extends OncePerRequestFilter {
public static final String COOKIE = "UIUX_CLIENT";
public static final String ATTR = "clientId";
private static final Pattern UUID_PATTERN = Pattern.compile("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String id = readCookie(request);
if (id == null) {
id = UUID.randomUUID().toString();
ResponseCookie cookie = ResponseCookie.from(COOKIE, id)
.path("/").httpOnly(true).sameSite("Lax").maxAge(Duration.ofDays(365)).build();
response.addHeader("Set-Cookie", cookie.toString());
}
request.setAttribute(ATTR, id);
chain.doFilter(request, response);
}
private static String readCookie(HttpServletRequest request) {
if (request.getCookies() == null) return null;
for (Cookie c : request.getCookies()) {
if (COOKIE.equals(c.getName()) && UUID_PATTERN.matcher(c.getValue()).matches()) return c.getValue();
}
return null;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
return uri.startsWith("/css/") || uri.startsWith("/js/") || uri.startsWith("/art/") || uri.endsWith(".svg");
}
}
src/main/java/com/study/uiux/web/LayoutAdvice.java
The server renders the saved appearance into <html>
@ModelAttribute
public void layout(HttpServletRequest request, Model model) {
String clientId = (String) request.getAttribute(ClientIdFilter.ATTR);
UiPreference pref = preferences.load(clientId);
model.addAttribute("pref", pref);
model.addAttribute("rootStyle", String.format(Locale.ROOT, "--font-scale:%.2f;--primary:%s;--on-primary:%s",
pref.getFontScale() / 100.0, pref.getThemeColor(), PreferenceService.onColor(pref.getThemeColor())));
// "system" cannot be known on the server; a tiny inline script in <head> resolves it before paint.
model.addAttribute("initialTheme", "dark".equals(pref.getThemeMode()) ? "dark" : "light");
model.addAttribute("groups", catalog.groups());
model.addAttribute("lessonCount", catalog.all().size());
model.addAttribute("presets", PreferenceService.PRESETS);
model.addAttribute("shortcutList", shortcuts.all().stream()
.map(s -> Map.of("id", s.getId(), "keys", s.getKeys(), "action", s.getActionCode(), "description", s.getDescription(), "scope", s.getScope()))
.toList());
model.addAttribute("lessonIndex", catalog.all().stream()
.map(l -> Map.of("no", l.no(), "name", l.name(), "thai", l.thai(), "group", l.group(), "url", l.url()))
.toList());
model.addAttribute("path", request.getRequestURI());
}
src/main/java/com/study/uiux/web/PreferenceApi.java
PUT /api/preferences: the browser sends only what changed
@PutMapping
public Map<String, Object> update(@RequestAttribute(ClientIdFilter.ATTR) String clientId, @RequestBody PreferenceUpdate change) {
return view(preferences.update(clientId, change));
}
src/main/java/com/study/uiux/service/PreferenceService.java
Validate and save one change
@Transactional
public UiPreference update(String clientId, PreferenceUpdate change) {
UiPreference pref = repository.findById(clientId).orElseGet(() -> new UiPreference(clientId));
if (change.themeMode() != null) {
String mode = change.themeMode().toLowerCase(Locale.ROOT);
if (!MODES.contains(mode)) throw ApiException.badRequest("Theme mode must be light, dark or system.");
pref.setThemeMode(mode);
}
if (change.fontScale() != null) {
int scale = change.fontScale();
if (scale < FONT_MIN || scale > FONT_MAX) {
throw ApiException.badRequest("Font size must be between " + FONT_MIN + "% and " + FONT_MAX + "%.");
}
pref.setFontScale(scale);
}
if (change.themeColor() != null) {
String hex = change.themeColor().toLowerCase(Locale.ROOT);
if (!HEX.matcher(hex).matches()) throw ApiException.badRequest("Theme colour must look like #4f46e5.");
pref.setThemeColor(hex);
}
if (change.sidebarCollapsed() != null) pref.setSidebarCollapsed(change.sidebarCollapsed());
return repository.save(pref);
}
src/main/java/com/study/uiux/domain/UiPreference.java
UiPreference entity
@Entity
@Table(name = "ui_preference")
public class UiPreference {
@Id
@Column(name = "client_id", length = 36)
private String clientId;
/** light, dark or system (follow the operating system). */
@Column(name = "theme_mode", nullable = false, length = 10)
private String themeMode = "system";
/** Root font size in percent: 100 = 16px. */
@Column(name = "font_scale", nullable = false)
private int fontScale = 100;
/** Primary colour as #rrggbb. */
@Column(name = "theme_color", nullable = false, length = 7)
private String themeColor = "#4f46e5";
@Column(name = "sidebar_collapsed", nullable = false)
private boolean sidebarCollapsed;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected UiPreference() {
}
public UiPreference(String clientId) {
this.clientId = clientId;
}
@PrePersist
void onCreate() {
createdAt = updatedAt = Instant.now();
}
@PreUpdate
void onUpdate() {
updatedAt = Instant.now();
}
src/main/resources/db/migration/V1__schema.sql
ui_preference: one row per browser
-- Appearance settings. The browser keeps only a random id in the UIUX_CLIENT cookie;
-- the server reads this row before rendering, so the page never "flashes" the wrong theme.
CREATE TABLE ui_preference (
client_id NVARCHAR(36) NOT NULL CONSTRAINT pk_ui_preference PRIMARY KEY,
theme_mode NVARCHAR(10) NOT NULL CONSTRAINT df_pref_mode DEFAULT N'system',
font_scale INT NOT NULL CONSTRAINT df_pref_font DEFAULT 100,
theme_color NVARCHAR(7) NOT NULL CONSTRAINT df_pref_color DEFAULT N'#4f46e5',
sidebar_collapsed BIT NOT NULL CONSTRAINT df_pref_side DEFAULT 0,
created_at DATETIME2(0) NOT NULL CONSTRAINT df_pref_created DEFAULT SYSUTCDATETIME(),
updated_at DATETIME2(0) NOT NULL CONSTRAINT df_pref_updated DEFAULT SYSUTCDATETIME(),
CONSTRAINT ck_pref_mode CHECK (theme_mode IN (N'light', N'dark', N'system')),
CONSTRAINT ck_pref_font CHECK (font_scale BETWEEN 80 AND 140),
CONSTRAINT ck_pref_color CHECK (theme_color LIKE N'#[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]')
);
src/main/resources/templates/layout.html
<html> carries the saved appearance, so CSS is right before any JS runs
<html lang="en" th:fragment="page(content)" xmlns:th="http://www.thymeleaf.org"
th:attr="data-theme=${initialTheme},data-mode=${pref.themeMode}"
th:style="${rootStyle}"
th:classappend="${pref.sidebarCollapsed} ? 'sidebar-collapsed'">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${title} + ' · UI/UX Lab'">UI/UX Lab</title>
<!-- "System" mode can only be resolved in the browser: do it before the first paint. -->
<script>(function () { var d = document.documentElement; if (d.dataset.mode === 'system') { d.dataset.theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } })();</script>
src/main/resources/templates/layout.html
Appearance panel: every control here calls UI.prefs.set()
<aside class="drawer" id="appearance" aria-label="Appearance" aria-hidden="true" inert>
<div class="drawer-head">
<h2>Appearance</h2>
<button class="icon-btn" type="button" data-action="appearance-close" aria-label="Close"><svg class="i"><use href="#i-x"/></svg></button>
</div>
<div class="drawer-body">
<section class="setting">
<h3>Mode</h3>
<div class="seg" role="radiogroup" aria-label="Colour mode">
<button type="button" role="radio" data-pref-mode="light"><svg class="i"><use href="#i-sun"/></svg>Light</button>
<button type="button" role="radio" data-pref-mode="dark"><svg class="i"><use href="#i-moon"/></svg>Dark</button>
<button type="button" role="radio" data-pref-mode="system"><svg class="i"><use href="#i-system"/></svg>System</button>
</div>
</section>
<section class="setting">
<h3>Text size <output class="pref-font-label">100%</output></h3>
<div class="font-row">
<button type="button" class="btn btn-sm btn-ghost" data-pref-font-step="-10" aria-label="Smaller text">A−</button>
<input type="range" min="80" max="140" step="5" data-pref-font aria-label="Text size">
<button type="button" class="btn btn-sm btn-ghost" data-pref-font-step="10" aria-label="Larger text">A+</button>
</div>
</section>
<section class="setting">
<h3>Theme colour</h3>
<div class="swatches">
<button type="button" th:each="p : ${presets}" class="swatch" th:style="'--sw:' + ${p.hex}"
th:attr="data-pref-color=${p.hex},aria-label=${p.name},data-tip=${p.name}"></button>
<label class="swatch swatch-custom" data-tip="Custom colour">
<input type="color" data-pref-color-input aria-label="Custom colour">
</label>
</div>
</section>
<section class="setting">
<label class="switch-row">
<span><b>Collapsed sidebar</b><small>Icons only, names in tooltips</small></span>
<input type="checkbox" class="switch" data-pref-sidebar>
</label>
</section>
<p class="muted small">Saved in SQL Server for this browser (table <code>ui_preference</code>).</p>
<button type="button" class="btn btn-ghost w-full" data-action="prefs-reset"><svg class="i"><use href="#i-refresh"/></svg>Reset to defaults</button>
</div>
</aside>
src/main/resources/static/css/app.css
Light tokens: every colour in the app comes from these variables
:root {
--font-scale: 1;
--primary: #4f46e5;
--on-primary: #ffffff;
--bg: #f5f6fb;
--surface: #ffffff;
--surface-2: #f2f4f9;
--surface-3: #e8ebf3;
--border: #e2e6ef;
--border-strong: #cdd3e1;
--text: #0f172a;
--text-2: #475569;
--text-3: #5f6b7f;
--primary-text: color-mix(in srgb, var(--primary) 82%, #000);
--primary-soft: color-mix(in srgb, var(--primary) 11%, var(--surface));
--primary-soft-2: color-mix(in srgb, var(--primary) 20%, var(--surface));
--primary-hover: color-mix(in srgb, var(--primary) 86%, #000);
--ring: color-mix(in srgb, var(--primary) 45%, transparent);
--success: #15803d; --success-soft: #dcfce7;
--warning: #b45309; --warning-soft: #fef3c7;
--danger: #dc2626; --danger-soft: #fee2e2;
--info: #2563eb; --info-soft: #dbeafe;
--shadow-sm: 0 1px 2px rgb(15 23 42 / .06);
--shadow: 0 1px 3px rgb(15 23 42 / .06), 0 6px 16px -6px rgb(15 23 42 / .12);
--shadow-lg: 0 10px 40px -10px rgb(15 23 42 / .28), 0 2px 8px rgb(15 23 42 / .08);
--radius-sm: .5rem;
--radius: .75rem;
--radius-lg: 1.125rem;
--sidebar-w: 16.5rem;
--rail-w: 4.5rem;
--topbar-h: 3.75rem;
--font: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Noto Sans Thai", "Leelawadee UI", Roboto, sans-serif;
--mono: "Cascadia Code", "JetBrains Mono", Consolas, ui-monospace, monospace;
--ease: cubic-bezier(.2, .8, .2, 1);
color-scheme: light;
}
src/main/resources/static/js/core.js
UI.prefs.set(): apply at once, save to the server a moment later
const prefs = (() => {
const state = Object.assign({ themeMode: 'system', fontScale: 100, themeColor: '#4f46e5', sidebarCollapsed: false }, cfg.prefs);
const dark = matchMedia('(prefers-color-scheme: dark)');
let pending = {}, saveTimer = null;
function resolveTheme(mode) { return mode === 'system' ? (dark.matches ? 'dark' : 'light') : mode; }
function apply(change) {
if (change.themeMode !== undefined) {
root.classList.add('theme-anim');
root.dataset.mode = change.themeMode;
root.dataset.theme = resolveTheme(change.themeMode);
setTimeout(() => root.classList.remove('theme-anim'), 300);
}
if (change.fontScale !== undefined) root.style.setProperty('--font-scale', (change.fontScale / 100).toFixed(2));
if (change.themeColor !== undefined) {
root.style.setProperty('--primary', change.themeColor);
root.style.setProperty('--on-primary', onColor(change.themeColor));
}
if (change.sidebarCollapsed !== undefined) root.classList.toggle('sidebar-collapsed', change.sidebarCollapsed);
Object.assign(state, change);
document.dispatchEvent(new CustomEvent('prefs:change', { detail: { ...state } }));
}
function set(change) {
apply(change);
Object.assign(pending, change);
clearTimeout(saveTimer);
saveTimer = setTimeout(save, 300); // dragging the slider sends one PUT, not fifty
}
async function save() {
const body = pending; pending = {};
try {
await api('/api/preferences', { method: 'PUT', body, silent: true });
} catch (e) {
toast({ type: 'error', title: 'Not saved', message: e.message });
}
}
async function reset() {
const saved = await api('/api/preferences', { method: 'DELETE' });
apply({ themeMode: saved.themeMode, fontScale: saved.fontScale, themeColor: saved.themeColor, sidebarCollapsed: saved.sidebarCollapsed });
toast({ type: 'success', title: 'Appearance reset', message: 'The ui_preference row for this browser was deleted.' });
}
// "System" follows the operating system live.
dark.addEventListener('change', () => { if (state.themeMode === 'system') apply({ themeMode: 'system' }); });
return { get: () => ({ ...state }), set, reset, resolveTheme, toggleTheme: () => set({ themeMode: root.dataset.theme === 'dark' ? 'light' : 'dark' }) };
})();
src/main/resources/static/js/demos.js
Contrast ratio = (L1 + 0.05) / (L2 + 0.05) from computed colours
function parseRgb(value) {
let m = value.match(/rgba?\(([\d.]+)[, ]+([\d.]+)[, ]+([\d.]+)/);
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
m = value.match(/color\(srgb ([\d.e-]+) ([\d.e-]+) ([\d.e-]+)/); // color-mix() results come back like this
if (m) return [m[1] * 255, m[2] * 255, m[3] * 255];
return [0, 0, 0];
}
function luminance([r, g, b]) {
const ch = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b);
}
function contrast(a, b) {
const la = luminance(parseRgb(a)), lb = luminance(parseRgb(b));
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}