import type { Palette } from './palette'; import { UI, contrast, ensureContrast, mix, rgbTriplet } from './contrast'; // Omarchy palette → Officer's 84 declarations. // // This file is the whole port. It exists once so that twenty-two themes cannot disagree about what // "muted" means, and so that improving the mapping improves all of them at once. // // ── What went wrong before, and what this fixes ── // // The previous port produced 34 declarations per theme against a contract of 84. The missing fifty were // every surface (`--panel-bg`, `--card-bg`, `--glass-bg`), every state colour (`--success`, `--warning`, // `--busy`, `--info`), the header, the scrollbars and the grid — so they fell back to whatever the base // stylesheet or the previous theme left behind. That is why highlights masked content and text faded // out: not taste, absence. It also shipped thirteen tokens nothing reads (`--chart-1..5`, `--sidebar-*`), // which is the tell that it came from a shadcn template rather than from this codebase's contract. // // ── The rule that makes them readable ── // // Every foreground is run through `ensureContrast` against the surface it actually sits on. A palette // whose colours are already readable is untouched; one that is not gets nudged toward black or white // until it passes, keeping as much hue as it can. So a theme cannot ship with invisible text, no matter // what its author chose. export type Tokens = Record; /** Depth ordering differs by mode: a light theme's "darker" is its raised surface, not its page. */ function surfaces(p: Palette) { const dark = p.mode === 'dark'; return { /** The page. The deepest surface in dark, the plainest in light. */ page: dark ? p.darkerBackground : p.background, /** Panels and cards sit one step up from the page, so a tile reads as a tile. */ raised: dark ? p.background : p.lighterBackground, /** Hovers, secondary fills, the header. One step up again. */ high: dark ? p.lighterBackground : p.darkBackground, dark, }; } export function mapPalette(p: Palette): Tokens { const s = surfaces(p); // Text, checked against the surface each weight actually appears on. const fg = ensureContrast(p.foreground, s.page); const fgRaised = ensureContrast(p.foreground, s.raised); // Checked against `s.high`, not `s.raised`: `--muted-foreground` is the text ON `--muted`, and // `--muted` is the high surface. Deriving it against the wrong one left everforest at 4.44:1 and // miasma at 4.00 — close enough to look fine and still fail, which is the kind of near-miss that a // person eyeballing a screenshot would have shipped. const fgMuted = ensureContrast(p.lightForeground, s.high); const fgFaint = ensureContrast(p.darkForeground, s.raised, UI); /** * Text for a solid fill of `bg`. * * Tries the palette's own extremes first, so a button's label still belongs to the theme, and only * falls through to `ensureContrast` when neither is readable. Both steps are needed: picking the * better of two candidates does NOT guarantee either passes — rose-pine's fills are all mid-tone and * every one came out between 2.2:1 and 4.2:1 until this drove it the rest of the way. */ const on = (bg: string): string => { const light = p.brightForeground; const dark = p.darkerBackground; const better = contrast(light, bg) >= contrast(dark, bg) ? light : dark; return ensureContrast(better, bg); }; const accent = p.accent; // Borders are DECORATION, not information, so they are not held to a text ratio — a 3:1 line drawn // around every panel is a different design, not a more accessible one. What they must be is // discernible, which `--input` below is checked for: a form field whose boundary you cannot see is a // real accessibility failure, unlike a subtle panel edge. const border = mix(s.raised, s.dark ? p.lightForeground : p.darkForeground, 0.28); return { // ── Ground ── '--background': s.page, '--foreground': fg, // ── Surfaces ── '--card': s.raised, '--card-foreground': fgRaised, '--popover': s.raised, '--popover-foreground': fgRaised, // `muted` is a SURFACE here, not the palette's `muted` colour — that one is a mid-grey meant for // dimmed text, and using it as a fill is precisely how a highlight ends up masking its content. '--muted': s.high, '--muted-foreground': fgMuted, '--accent': s.high, '--accent-foreground': fgRaised, // ── Actions ── '--primary': accent, '--primary-foreground': on(accent), '--secondary': s.high, '--secondary-foreground': fgRaised, '--cta': accent, '--cta-foreground': on(accent), '--ctahover': mix(accent, s.dark ? '#ffffff' : '#000000', 0.12), // ── State — five tones, each readable on its own fill ── '--success': p.green, '--success-foreground': on(p.green), '--warning': p.yellow, '--warning-foreground': on(p.yellow), '--destructive': p.red, '--destructive-foreground': on(p.red), '--info': p.blue, '--info-foreground': on(p.blue), '--busy': p.orange, '--busy-foreground': on(p.orange), // The odd one, per the contract: a fill bright enough for black text is too light to BE text. So ink // is the same hue pushed until it reads on the page. '--busy-ink': ensureContrast(p.orange, s.page), // ── Lines ── '--border': border, '--input': border, '--ring': accent, '--radius': '0.625rem', // ── Data ── '--chart-primary': accent, '--chart-secondary': p.magenta, '--status-success': p.green, '--status-warning': p.yellow, '--status-in-progress': p.blue, // ── Surfaces, as complete CSS values ── '--page-bg': s.page, '--page-bg-src': "''", '--panel-bg': `rgb(${rgbTriplet(s.raised)} / 0.72)`, '--panel-border': `rgb(${rgbTriplet(border)} / 0.9)`, '--panel-border-bare': `rgb(${rgbTriplet(border)} / 0.55)`, // DENSER, not darker — panel chrome is dark text and a heavy fill hides every icon. The contract // says so; this is the one place a value is chosen to obey a sentence rather than a ratio. '--panel-raised-bg': `rgb(${rgbTriplet(s.raised)} / 0.95)`, '--panel-raised-border': `rgb(${rgbTriplet(border)} / 1)`, '--panel-accent': accent, '--panel-header-bg': `rgb(${rgbTriplet(s.high)} / 0.6)`, '--panel-header-fg': fgRaised, '--panel-radius': '0.75rem', '--panel-blur': '12px', '--card-bg': s.raised, '--glass-bg': `rgb(${rgbTriplet(s.raised)} / 0.55)`, '--glass-border': `rgb(${rgbTriplet(border)} / 0.7)`, '--dock-bg': `rgb(${rgbTriplet(s.raised)} / 0.7)`, '--dock-border': `rgb(${rgbTriplet(border)} / 0.8)`, '--dock-tooltip-bg': s.high, '--header-bg': `rgb(${rgbTriplet(s.raised)} / 0.8)`, '--header-border': `rgb(${rgbTriplet(border)} / 0.9)`, '--header-fg': fgRaised, '--header-blur': '12px', // Grid lines are a texture, not a colour anyone should notice — kept just above the surface. '--pixel-grid-color': `rgb(${rgbTriplet(fgFaint)} / 0.07)`, '--card-grid-color': `rgb(${rgbTriplet(fgFaint)} / 0.05)`, '--panel-grid-color': `rgb(${rgbTriplet(fgFaint)} / 0.06)`, '--scrollbar-thumb': `rgb(${rgbTriplet(p.muted)} / 0.6)`, '--scrollbar-thumb-hover': `rgb(${rgbTriplet(p.muted)} / 0.85)`, '--scrollbar-thin-thumb': `rgb(${rgbTriplet(p.muted)} / 0.45)`, '--scrollbar-thin-thumb-hover': `rgb(${rgbTriplet(p.muted)} / 0.7)`, // ── Brand slots ── // // A ported theme has no brand of its own, so these follow its accent rather than importing Duck's // green into somebody else's palette. `--ink` is the app's text colour and is used ~950 times. '--brand': accent, '--brand-foreground': on(accent), '--brand-primary': accent, '--brand-accent': p.cyan, '--brand-warm': p.orange, '--brand-deep': s.dark ? p.darkerBackground : p.darkBackground, '--brand-muted': p.muted, '--ink': fg, '--surface-warm': s.raised, '--naturegreen': p.green, '--naturegreen-foreground': on(p.green), // Legacy aliases the contract keeps until the utility rename lands — `prose.css` and some plugins // read them directly, so a theme without them has unstyled prose. '--duck-dark': fg, '--duck-teal': p.cyan, '--duck-yellow': p.yellow, '--duck-orange': p.orange, '--duck-forest': p.green, '--duck-beige': mix(s.raised, fg, 0.1), '--logo-src': "'/officer-logo.svg'", }; }