Single source of truth for Luie's visual & interaction design. Every rule here is grounded in this repository's code (file paths cited inline). External references are folded in where they shaped a decision. Written so an agent (Codex/Claude) can reproduce Luie's look without guessing.
Golden rule: never hardcode a color, radius, font, or z-index. Consume the tokens. If a value isn't a token yet, add it to
global.tokens.cssfirst.
시각·상호작용 변경에 관련된 절만 읽는다. 아래 값·줄 번호는 설명용 스냅샷이며 현재 CSS·컴포넌트 구현과 대조한다. 기존 token·사용자 테마·레이아웃 저장 계약을 재사용하고, 좌표·사용자 색상 등 런타임 데이터는 해당 API의 동적 값을 유지한다. 문서 예시만으로 새 token·의존성·승인 단계를 추가하지 않는다.
Luie is an Electron desktop writing app (novel/worldbuilding). The design intent is encoded directly in the token comments:
- A "recessed editor" model (see
global.tokens.css:231): the editor is the dark focal writing surface; the chrome (sidebar, toolbar, footer) sits one calm step lighter as the frame. In light themes it inverts — the editor is paper, the chrome is a slightly tinted frame. - Calm, neutral, professional. Zinc neutral scale, a single muted-brass brand accent, no pure black, subtle cool tint in dark (pure greys "read drab"). The comments explicitly calibrate against Notion, Linear, Vercel, Tokyo Night, Gruvbox, Everforest, Solarized.
- Writer-focused. A dedicated Sepia ("warm paper") theme exists for long-form writing comfort, plus serif font support (KoPub Batang etc.).
- App-like, not web-like.
body { overflow: hidden }(global.behaviors.css:21); the whole surface is a fixed-viewport workspace of resizable panels, not a scrolling document.
Design north star: the writing surface is sacred; chrome recedes. When in doubt, lower the contrast/visual weight of chrome and raise it on content.
Luie uses Tailwind CSS v4 with a CSS-first config (there is no
tailwind.config.js — it was removed). The wiring lives in
global.css:
@import "tailwindcss";
@import "./global.tokens.css"; /* tokens + @theme */
@import "./global.behaviors.css"; /* base element styles + motion */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));- Raw tokens —
--bg-app,--text-primary,--accent-bg, … defined on:rootand redefined per theme via attribute selectors. - Tailwind
@themealiases — map raw tokens to utility namespaces sobg-app,text-fg,border,text-accent, etc. become real classes (global.tokens.css:1-72).
Because utilities resolve to var(--raw-token), switching a theme attribute
re-themes the entire app instantly with no class changes.
| Attribute | Values | Effect |
|---|---|---|
data-theme |
light (default) · dark · sepia |
Full palette swap |
data-temp |
cool · warm (neutral = unset) |
Re-tints only the bg ladder + primary text |
data-contrast |
high (unset = normal) |
Stronger text + borders (a11y) |
data-animations |
on · off |
Master motion switch (see §8) |
data-layout-restoring |
true while panels reflow |
Suppresses transitions during programmatic layout |
data-temp × data-theme produces a matrix (e.g. dark+cool ≈ Tokyo Night,
light+warm ≈ Solarized Light, sepia+warm = deep amber paper). Structure never
changes across temperatures — only the background ladder and text.
External alignment: the Vercel Web Interface Guidelines require honoring contrast and reduced-motion. Luie implements both as first-class user controls (
data-contrast="high",data-animations="off") and honors the OSprefers-reduced-motionmedia query globally (global.animations.css).
Always use the semantic utility, never the raw hex. The raw values exist only so themes can redefine them.
| Token / utility | Light | Dark (neutral) | Sepia | Role |
|---|---|---|---|---|
bg-app |
#ffffff |
#1a1a1c |
#fbf0d9 |
Editor / main canvas (focal) |
bg-sidebar |
#f4f4f5 |
#212123 |
#f5e6cc |
Chrome frame (one step off app) |
bg-panel |
#ffffff |
#28282b |
#fbf0d9 |
Floating surfaces (modal/inspector/dropdown) |
bg-surface |
#ffffff |
#28282b |
#fffefb |
Cards / inputs / popovers |
bg-element |
#ffffff |
#313135 |
#fffefb |
Top-of-stack controls/inputs |
bg-surface-hover / bg-active |
alpha black | alpha white | alpha brown | Hover/active overlays (alpha, not solid) |
Hover/active are alpha overlays (rgba(0,0,0,0.04) light, rgba(255,255,255,0.08)
dark), Vercel-style — so they compose over any surface. Don't invent solid hover
colors.
| Utility | Light | Dark | Role |
|---|---|---|---|
text-fg |
#18181b |
#d7d7da |
Primary |
text-muted |
#71717a |
#989aa2 |
Secondary |
text-subtle |
#a1a1aa |
#6c6e77 |
Tertiary / placeholder |
text-accent |
#795d2b |
#d0b178 |
Links / interactive |
- One accent only: muted brass (
--accent-bg #806330in light,#b79152in dark). Alternate accent swatches were deliberately removed (global.behaviors.css:2). Sepia uses the darker brass#8a602efor paper contrast. --success-fggreen,--danger-fgred,--accent-fg(on-accent) white.- Borders:
border(--border-default),border-active,border-focus. In dark, borders are white at 8% opacity — "enough for definition, not a grid prison" (global.tokens.css:266). - Wiki ("Namuwiki") tokens are aliased to core tokens so the wiki shares Luie's single palette — never give the wiki its own colors.
Font stacks (global.tokens.css:198-206),
Korean-first:
--font-sans:-apple-system, "Apple SD Gothic Neo", "Malgun Gothic", "Segoe UI", Roboto …--font-serif:"KoPub Batang", "Noto Serif KR", "Nanum Myeongjo", Merriweather, Georgia …(long-form writing)--font-mono:"D2Coding", "NanumGothicCoding", ui-monospace, Menlo …
body enables ligatures + contextual alternates and antialiasing
(global.behaviors.css:6-22):
font-feature-settings: "liga" 1, "calt" 1, -webkit-font-smoothing: antialiased,
text-rendering: optimizeLegibility.
Type scale is context-scoped via CSS variables, not a global ramp — e.g.
--context-panel-body-font-size: 13px, --memo-tag-font-size: 9px,
--world-overview-font-size: 14px. When building a feature, prefer a
feature-scoped size token over an inline text-[13px].
--font-weight-semibold: 600 is the heaviest UI weight; chrome text is 500.
From the @theme block (global.tokens.css:55-85):
| Token | Value | Use |
|---|---|---|
--radius-control (rounded-control) |
0.625rem (10px) |
Buttons, inputs, small controls |
--radius-panel (rounded-panel) |
0.875rem (14px) |
Panels, cards, dialogs |
--shadow-panel |
0 10px 28px rgba(15,23,42,.14) |
Floating panels |
--shadow-sm/md/lg |
standard ramp | Elevation |
--spacing-control-x / -y |
0.75rem / 0.5rem |
Control padding |
--spacing-panel-pad |
1.25rem |
Panel inner padding |
--spacing-panel-gap |
1rem |
Gap between panel items |
Z-index is a 4-step named scale — do not reintroduce ad-hoc
z-[9999]. Use the @utility classes:
| Utility | Value | Layer |
|---|---|---|
z-dropdown |
50 | context menus, popovers, tooltips |
z-banner |
100 | sticky top banners (offline) |
z-toast |
150 | transient notifications |
z-modal |
1000 | full-surface dialogs + floating panels |
Fixed app dimensions: --header-height: 48px, --sidebar-width: 260px,
--panel-width: 320px.
- Library: lucide-react (e.g.
PanelLeftClose,PanelRightOpeninMainLayout.tsx). - Sizes are tokenized (
--icon-size-xs 12 → -xxxl 32) with.icon-xs … .icon-xxxlutility classes (global.behaviors.css:24-58). Toolbar icons are 16px (--editor-toolbar-icon-size). - Icon-only buttons must carry
aria-label(andtitle) — see the sidebar toggles inMainLayout.tsxfor the pattern. This is also a hard rule in the Vercel guidelines.
Luie has four top-level layouts, selected by app mode, all built on
react-resizable-panels v4 (Group / Panel / Separator API):
| Layout | File | Shape |
|---|---|---|
| Main (default) | MainLayout.tsx |
Sidebar │ Editor │ Binder (context) |
| Editor | EditorRoot.tsx |
Focused editor variant |
| Google Docs | GoogleDocsLayout.tsx |
Doc-style with right rail |
| Scrivener | ScrivenerLayout.tsx |
Sidebar │ Editor split │ Inspector |
┌──────────┬───────────────────────────┬──────────┐
│ sidebar │ main-content │ context │
│ (left, │ (editor; nested split │ (binder; │
│ collaps- │ group for split view) │ right, │
│ ible) │ │ collaps- │
│ │ │ ible) │
└──────────┴───────────────────────────┴──────────┘
one `main-layout-group` (horizontal, %-based)
Key, non-obvious layout rules learned from this codebase (uphold them):
- One horizontal
PanelGroupholds all three panels. Both end panels (sidebar-panel,context-panel) arecollapsible collapsedSize={0}; the middle (main-content-panel) is the flex absorber. Dragging a separator must only move the adjacent pair — the middle absorbs the delta. - Sizes persist as ratios/px in the UI store, keyed by surface
(
default.sidebar,default.panel, …) — seelayoutSizing.tsanduseLayoutPersist.ts. Sidebar and binder use different keys — they are not coupled state. - Open/close uses a presence hook
(
useResizablePanelPresence.ts) that drives the panel imperatively and exposesisOpening/isClosing/shouldRender. - Research/inner sidebars use a fixed-pixel layout hook
(
useFixedPixelPanelGroupLayout.ts): the sidebar holds a fixed px width, the content panel flexes. Their widths persist per feature (characterSidebar,eventSidebar, …) and only user drags persist — programmatic relayouts must not write width (guarded viadata-layout-restoring, seeuseSidebarResizeCommit.ts).
Motion is globally gated by attributes, not scattered media queries
(global.behaviors.css:65-89):
html[data-animations="off"] *→ allanimation-duration/transition-durationforced to0ms. This is a user setting (Settings → Appearance → 애니메이션).html[data-layout-restoring="true"] *→ same kill-switch while panels are being programmatically laid out, so reflow never animates/flickers.- Panel resize transition is opt-in per element:
html[data-animations="on"] [data-panel][data-panel-animated="true"]transitions onlyflex-basis, flex-grow, width, max-widthover200mscubic-bezier(0.2,0,0,1). Critically,data-panel-animatedis gated so it is off during an active separator drag (otherwise the eased flex writes make the opposite panel visibly drift). Enter/exit slides use the project's ownanimate-in/animate-oututilities (global.animations.css), applied only during the opening/closing window.
Standard duration: 200ms. Standard easing: cubic-bezier(0.2,0,0,1)
(decelerate). Match these.
Enter/exit utilities are defined in-repo, not by a plugin. These class names
(animate-in, fade-in, zoom-in-95, slide-in-from-*, slide-out-to-*) look
like tailwindcss-animate, and this document previously claimed that plugin was
in use — it never was. The package was absent, no @utility/@keyframes
defined it, and the built CSS emitted zero matching rules, so ~90 usages across
21 files were dead classes. They are now implemented directly because Tailwind v4
exposes --tw-duration / --tw-ease from duration-* / ease-* (the main
plumbing a plugin used to provide), so the existing class names work unchanged and
default to the 200ms / cubic-bezier(0.2,0,0,1) convention above instead of a
third-party default.
External alignment: the Vercel guidelines say animate
transform/opacityonly, nevertransition: all, honorprefers-reduced-motion. Luie lists properties explicitly (neverall), has the in-appdata-animationsmaster switch, and now also honors the OS@media (prefers-reduced-motion: reduce)globally (global.animations.css). Previously that media query existed only incanvas.css, so elements outside the canvas were not covered.
Do:
- Compose semantic utilities:
bg-panel,bg-surface-hover,text-fg,text-muted,border border-border,rounded-control,rounded-panel,shadow-panel,z-dropdown. These already theme correctly. - Hover/active/focus must raise contrast above rest (guideline + Luie's alpha
overlays). Every interactive element needs a visible
hover:and afocus-visible:ring (--color-ring→ accent). - Icon buttons:
aria-label+title; use theMainLayouttoggle buttons as the canonical example. - Dark mode: use the
dark:variant (wired to[data-theme="dark"]) or just let tokens cascade — prefer token cascade. - Per-feature sizing tokens over inline pixel values.
Don't:
- ❌ Hardcode hex /
rgb()in components — breaks 6 theme combinations. - ❌
z-[9999]or new magic z-indices — use the named scale. - ❌
transition: all/transition-all— list properties (matches thedata-panel-animatedrule). - ❌ Solid hover backgrounds — use the alpha
bg-surface-hover/bg-active. - ❌ Give the wiki/binder/any feature a private color palette.
- ❌
outline-nonewithout afocus-visiblereplacement.
- Zustand is the only state lib. UI/layout state lives in the workspace
uiStore(persisted, versioned, zod-validated, migrated — seeuiStore.persist.ts). - Theme/appearance (
data-theme,data-temp,data-contrast,enableAnimations) is user state persisted across sessions and applied as the HTML attributes above. Treat these attributes as the rendering contract. - Panel widths, collapsed state, open/closed all persist (globally and per-project). Respect the rule: only user interaction writes layout state; programmatic relayout must not.
External source: Vercel Web Interface Guidelines
(https://github.com/vercel-labs/web-interface-guidelines). Apply on every UI
change:
- Interactive elements are real
<button>/<a>(no clickable<div>/<span>). - Icon-only controls have
aria-label; inputs have associated<label>. - Visible
hover:andfocus-visible:states; states increase contrast. - No
outline-nonewithout a focus-visible ring. - Motion: prefer
transform/opacity; panel presence/resize follows §7–8, uses explicit properties, and honors bothdata-animations="off"andprefers-reduced-motion. - Animations are interruptible (the presence hook already supports this).
- Contrast holds in all themes; verify
data-contrast="high". - Touch/drag: disable text selection during drag;
touch-action: manipulationon tappable controls. - Large-list rendering is measured at realistic input sizes; use virtualization
when the observed cost warrants it. No
getBoundingClientRect/offsetHeightreads during render. - Dates/numbers via
Intl.*, not hardcoded formats.
SURFACES bg-app bg-sidebar bg-panel bg-surface bg-element
OVERLAYS bg-surface-hover bg-active (alpha, compose over surfaces)
TEXT text-fg text-muted text-subtle text-accent text-on-accent
BRAND accent (muted brass; theme-adaptive) success danger
BORDER border (soft) border-active border-strong (UI 경계 3:1) border-focus
· data-contrast="high" 는 border 를 한 단계 진하게
RADIUS rounded-control (10px) rounded-panel (14px)
SHADOW shadow-control (theme tint) shadow-panel · Tailwind 기본 검정 그림자 금지
Z-INDEX z-dropdown 50 · z-banner 100 · z-toast 150 · z-modal 1000
MOTION 200ms cubic-bezier(0.2,0,0,1) · gated by data-animations / data-layout-restoring / prefers-reduced-motion
· animate-in|out + fade|zoom|slide 는 global.animations.css 정의
FONTS --font-sans (KR-first) · --font-serif (writing) · --font-mono
THEME ATTRS data-theme · data-temp · data-contrast · data-animations
If you remember one thing: Luie themes through tokens + HTML attributes.
Consume bg-app/text-fg/rounded-panel/z-modal; never hardcode. The editor
is the focal surface; chrome recedes one calm step.