Feature 307 Layout & theme

Dark Mode โหมดมืด

A dark colour scheme built only from CSS custom properties, saved per browser in SQL Server and rendered by the server so it never flashes.

Live demo

Everything here talks to the real Spring Boot + SQL Server back end.

Choose a mode

The choice is saved in SQL Server (ui_preference.theme_mode). Reload the page: the server prints data-theme on <html>, so there is no flash.

What the page knows right now

-saved mode (theme_mode)
-active theme (data-theme)
-OS prefers-color-scheme

Dark tokens

Each box paints one CSS variable. Switch modes and watch the values change.

What to notice

  • All colours are tokens (--bg, --surface, --text ...) redefined under [data-theme=dark].
  • The server reads ui_preference before rendering and prints data-theme on <html>.
  • 'System' follows prefers-color-scheme and updates live when the OS switches.

Try it

Source code

10 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 Dark tokens: same names, darker values. Components never change.
:root[data-theme="dark"] {
  --bg: #0a0f1d;
  --surface: #111829;
  --surface-2: #172036;
  --surface-3: #1f2a44;
  --border: #243050;
  --border-strong: #33406a;
  --text: #e6eaf5;
  --text-2: #b0b9d0;
  --text-3: #94a0bb;
  --primary-text: color-mix(in srgb, var(--primary) 62%, #fff);
  --primary-soft: color-mix(in srgb, var(--primary) 18%, var(--surface));
  --primary-soft-2: color-mix(in srgb, var(--primary) 30%, var(--surface));
  --primary-hover: color-mix(in srgb, var(--primary) 85%, #fff);

  --success: #4ade80; --success-soft: #12301f;
  --warning: #fbbf24; --warning-soft: #33270d;
  --danger: #f87171;  --danger-soft: #3a1418;
  --info: #60a5fa;    --info-soft: #132a4a;

  --shadow-sm: 0 0 0 1px rgb(255 255 255 / .03);
  --shadow: 0 0 0 1px rgb(255 255 255 / .04), 0 8px 24px -10px rgb(0 0 0 / .6);
  --shadow-lg: 0 0 0 1px rgb(255 255 255 / .06), 0 20px 50px -12px rgb(0 0 0 / .75);
  color-scheme: dark;
}
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' }) };
})();

Are you sure?

Rename

↑↓ moveEnter openEsc close