feat(ui): warm light theme and pre-mixed status fills

- Light mode moves to Claude's ivory/terracotta family: page #e8e5da, cards
  #f5f3ec (a notch below Claude's ivory, no pure-white step), warm
  borders and card shadow; dark mode is untouched.
- The surface ramp rises in lightness with its index in BOTH themes, so a
  card sits brighter than the page and every hover lifts instead of dims.
- Light --accent #a34a28 (the brand #cc785c is 3.1:1 on ivory); --fg-muted
  held at >=4.5:1; new --syn-* syntax tokens, dark keeps its pastels.
- Status/accent fills (bg-*/N, border-*/N) compile to color-mix against
  --tint-base instead of rgb(/a), so a chip is one colour on every surface.
- Hardcoded dark-only hovers (hover:text-white, hover:bg-white/5) replaced
  by tokens in DateTimePicker and SessionDrillIn.
This commit is contained in:
2026-09-23 13:23:00 +07:00
parent 952ee7c724
commit 1f5c04e765
8 changed files with 191 additions and 54 deletions
+1
View File
@@ -470,6 +470,7 @@ graph TD
### Splash & loading UX ### Splash & loading UX
- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies. - **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies.
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per theme, consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#a34a28` — Claude's terracotta two steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's `#0090ff`, which is 3.3:1 on white and fails AA; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`. - **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts). - **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover. - **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
+1 -1
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -53,6 +53,16 @@ The client is a single-page application (SPA) built with modern web technologies
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts - **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds - **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class - **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
Other rules that fall out of this:
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it two steps down, `#a34a28` — 5.3:1 as text on a card, 5.9:1 white-on-accent.
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
- Anything that assumes a dark background — a hardcoded near-black panel, `hover:text-white`, `filter: invert(1)` on a form control glyph — is a light-mode bug. Use tokens, or scope the rule under `.dark`.
- **A status/accent FILL is pre-mixed, never translucent.** `bg-status-success/10` and `border-status-danger/40` compile to `color-mix(… , rgb(var(--tint-base)))`, not to `rgb(… / 0.1)` — see `tint()` in `tailwind.config.js`. A translucent tint composites with whatever is behind it, so one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. `--tint-base` is the theme's card surface, so the chip reads the same on the page, in a card, in a modal and inside an input row. This is scoped to `backgroundColor`/`borderColor`: `text-status-warning/80` and `ring-accent/40` stay genuinely translucent (they sit *on* a fill), and the `surface` family keeps its alpha or every scrim would turn solid. Light `--status-*` is two steps darker than Radix 11 so a label clears 4.5:1 against its own densest chip (a 20% tint of itself), not just against the card.
- **React Router 6.28** - Client-side routing with nested layouts - **React Router 6.28** - Client-side routing with nested layouts
- **WebSocket** - Real-time event streaming from server - **WebSocket** - Real-time event streaming from server
- **Lucide Icons** - Modern, consistent icon set - **Lucide Icons** - Modern, consistent icon set
+7 -4
View File
@@ -174,7 +174,7 @@ export function DateTimePicker({
? "bg-accent text-white font-medium" ? "bg-accent text-white font-medium"
: isToday : isToday
? "bg-surface-3 text-accent font-medium" ? "bg-surface-3 text-accent font-medium"
: "hover:bg-surface-2 text-fg-secondary hover:text-white" : "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary"
}`} }`}
> >
{i} {i}
@@ -201,7 +201,10 @@ export function DateTimePicker({
{dateObj ? formatDisplay(dateObj) : placeholder} {dateObj ? formatDisplay(dateObj) : placeholder}
</span> </span>
{dateObj && ( {dateObj && (
<X className="w-3 h-3 text-fg-muted hover:text-white shrink-0" onClick={clearValue} /> <X
className="w-3 h-3 text-fg-muted hover:text-fg-primary shrink-0"
onClick={clearValue}
/>
)} )}
</button> </button>
@@ -216,7 +219,7 @@ export function DateTimePicker({
onClick={() => onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1)) setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
} }
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white" className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
</button> </button>
@@ -228,7 +231,7 @@ export function DateTimePicker({
onClick={() => onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1)) setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
} }
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white" className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
> >
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>
@@ -171,7 +171,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
return ( return (
<div> <div>
<div <div
className="flex items-center gap-2 py-1.5 hover:bg-white/5 rounded transition-colors" className="flex items-center gap-2 py-1.5 hover:bg-surface-4 rounded transition-colors"
style={{ paddingLeft: `${indentPx + 8}px`, paddingRight: "8px" }} style={{ paddingLeft: `${indentPx + 8}px`, paddingRight: "8px" }}
> >
{/* Depth connector line */} {/* Depth connector line */}
@@ -252,7 +252,7 @@ function ToolTimeline({ events }: ToolTimelineProps) {
{events.map((ev) => ( {events.map((ev) => (
<div <div
key={ev.id} key={ev.id}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors" className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors"
> >
{/* Tool pill */} {/* Tool pill */}
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap"> <span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
@@ -310,7 +310,7 @@ function EventSequence({ events }: EventSequenceProps) {
{recent.map((ev) => ( {recent.map((ev) => (
<div <div
key={ev.id} key={ev.id}
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors group" className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors group"
> >
{/* Event type badge */} {/* Event type badge */}
<span <span
@@ -454,7 +454,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors" className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.closePanel")} aria-label={t("drillIn.closePanel")}
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -615,7 +615,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
key={s.id} key={s.id}
type="button" type="button"
onClick={() => handleSelect(s.id)} onClick={() => handleSelect(s.id)}
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors border-b border-border/50 last:border-0" className="flex items-center gap-2 px-3 py-2 text-left hover:bg-surface-4 transition-colors border-b border-border/50 last:border-0"
> >
<span <span
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`} className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`}
@@ -654,7 +654,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
type="button" type="button"
onClick={handleLoadMore} onClick={handleLoadMore}
disabled={loading} disabled={loading}
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50" className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors border-t border-border/50 disabled:opacity-50"
> >
{loading ? t("drillIn.loading") : t("drillIn.loadMore")} {loading ? t("drillIn.loading") : t("drillIn.loadMore")}
</button> </button>
@@ -739,7 +739,7 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors" className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.close")} aria-label={t("drillIn.close")}
> >
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
+89 -23
View File
@@ -15,7 +15,10 @@
* 1-2 app/chrome background 6-8 borders (6 subtle, 7-8 stronger) * 1-2 app/chrome background 6-8 borders (6 subtle, 7-8 stronger)
* 3-5 component background 9-10 solid (buttons, vibrant fills) * 3-5 component background 9-10 solid (buttons, vibrant fills)
* 11-12 text (11 secondary, 12 primary) * 11-12 text (11 secondary, 12 primary)
* `slate` supplies surface/border/fg here; `blue` supplies accent; `green`/ * The DARK theme follows it literally: `slate` supplies surface/border/fg and
* `blue` supplies accent. The LIGHT theme keeps the structure (same roles, same
* rising-lightness ramp) but swaps the hue family for Claude's own warm ivory
* and terracotta, because a cool near-white page reads as glare. `green`/
* `red`/`amber` step 11 supplies the status tokens — the same step used for * `red`/`amber` step 11 supplies the status tokens — the same step used for
* fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are * fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to * the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
@@ -23,27 +26,70 @@
*/ */
:root { :root {
/* Light theme — the CSS default, so it applies with no class on <html>. /* Light theme — the CSS default, so it applies with no class on <html>.
Radix `slate` (light), steps 1/2/3/4/5/6/7. */ Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
--surface-0: 252 252 253; /* slate-1 #fcfcfd */ as it does in dark: the page is the DARKEST step and a card is brighter
--surface-1: 249 249 251; /* slate-2 #f9f9fb */ than the page it sits on. The previous light block used the scale in its
--surface-2: 240 240 243; /* slate-3 #f0f0f3 */ native direction, which inverted the whole language — a near-white page
--surface-3: 232 232 236; /* slate-4 #e8e8ec */ (glare) under grey cards that read as recessed, and every `hover:bg-*`
--surface-4: 224 225 230; /* slate-5 #e0e1e6 */ darkening instead of lifting.
--surface-5: 217 217 224; /* slate-6 #d9d9e0 */
--border: 217 217 224; /* slate-6 #d9d9e0 */ The HUE is warm — Claude's own ivory/terracotta family, not a cool slate.
--border-light: 205 206 214; /* slate-7 #cdced6 */ A cool near-white ramp kept reading as glare no matter how far the page
--accent: 0 144 255; /* blue-9 #0090ff */ step was pulled down, because the brightest thing on screen was also the
--accent-hover: 5 136 240; /* blue-10 #0588f0 */ bluest. Cream lowers the perceived glare at the same measured lightness
--fg-primary: 28 32 36; /* slate-12 #1c2024 */ and gives every neutral a warm cast to sit against. The whole ramp sits
--fg-secondary: 96 100 108; /* slate-11 #60646c */ one notch below Claude's own ivory (#faf9f5 card) and no step reaches pure
--fg-muted: 139 141 152; /* slate-9 #8b8d98 */ white: at full brightness a large light panel still tires the eye. */
--shadow-card: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.08); --surface-0: 232 229 218; /* #e8e5da — page background */
/* Status: green/red/amber step 11 (light) — the same "readable text" step --surface-1: 236 233 223; /* #ece9df — sub-headers inside a card */
as fg-secondary, so success/danger/warning sit at the same visual weight --surface-2: 240 237 228; /* #f0ede4 — inputs, chips, toolbars */
as ordinary secondary text, not shouting over it. */ --surface-3: 245 243 236; /* #f5f3ec — `.card`, Claude's ivory a step down */
--status-success: 33 131 88; /* green-11 #218358 */ --surface-4: 249 247 241; /* #f9f7f1 — hover / active lift */
--status-danger: 206 44 49; /* red-11 #ce2c31 */ --surface-5: 252 251 247; /* #fcfbf7 — topmost, rare */
--status-warning: 171 100 0; /* amber-11 #ab6400 */ --border: 207 201 184; /* #cfc9b8 */
--border-light: 222 217 204; /* #ded9cc */
/* Claude's terracotta (#cc785c), taken down two steps: the brand value is
3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and
fails as a link or a button label. This is 5.3:1 as text on a card and
5.9:1 white-on-accent. Dark mode keeps its blue — inverting a warm accent
onto a near-black page is a different problem with a different answer. */
--accent: 163 74 40; /* #a34a28 */
--accent-hover: 142 63 32; /* #8e3f20 */
--fg-primary: 31 30 27; /* #1f1e1b — 15.0:1 on a card */
--fg-secondary: 96 91 83; /* #605b53 — 6.1:1 */
/* Measured, not eyeballed: this carries real content (timestamps, counts),
so it is held above 4.5:1 — 5.4:1 on a card, 4.7:1 on the page — and still
sits a clear step below fg-secondary. */
--fg-muted: 104 99 89; /* #686359 */
/* Warm and soft, to match the surfaces: a neutral-grey shadow goes muddy
over cream. The SHADOW carries "raised" here, not the quiet border. */
--shadow-card: 0 1px 2px rgb(60 50 30 / 0.05), 0 2px 6px rgb(60 50 30 / 0.07);
/* Status: darkened two steps from Radix 11. One step cleared 4.5:1 against
the card; these also clear it against the status's OWN densest chip (a
20% tint of itself — 4.5:1), which is where the label actually sits. */
--status-success: 22 104 66; /* #166842 */
--status-danger: 170 36 41; /* #aa2429 */
--status-warning: 133 79 0; /* #854f00 */
/* What every status/accent FILL is pre-mixed against (see `tint()` in
tailwind.config.js) — the card surface, because that is where chips and
alert strips overwhelmingly sit. */
--tint-base: 245 243 236; /* = surface-3 */
/* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken
four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible
on a near-black code block and invisible on a light one. */
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
--syn-string: 4 120 87; /* emerald-700 #047857 */
--syn-number: 194 65 12; /* orange-700 #c2410c */
--syn-keyword: 109 40 217; /* violet-700 #6d28d9 */
--syn-builtin: 3 105 161; /* sky-700 #0369a1 */
--syn-function: 180 83 9; /* amber-700 #b45309 */
--syn-operator: 190 24 93; /* pink-700 #be185d */
--syn-punctuation: 107 114 128; /* gray-500 #6b7280 */
--syn-property: 14 116 144; /* cyan-700 #0e7490 */
--syn-tag: 190 18 60; /* rose-700 #be123c */
--syn-plain: 31 30 27; /* = fg-primary */
--syn-add: 4 120 87; /* emerald-700 */
--syn-del: 185 28 28; /* red-700 #b91c1c */
} }
.dark { .dark {
@@ -70,6 +116,21 @@
--status-success: 61 214 140; /* green-11 dark #3dd68c */ --status-success: 61 214 140; /* green-11 dark #3dd68c */
--status-danger: 255 149 146; /* red-11 dark #ff9592 */ --status-danger: 255 149 146; /* red-11 dark #ff9592 */
--status-warning: 255 202 22; /* amber-11 dark #ffca16 */ --status-warning: 255 202 22; /* amber-11 dark #ffca16 */
--tint-base: 39 42 45; /* = surface-3 */
/* Unchanged from the pastels this file has always drawn code in. */
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
--syn-string: 110 231 183; /* emerald-300 #6ee7b7 */
--syn-number: 253 186 116; /* orange-300 #fdba74 */
--syn-keyword: 196 181 253; /* violet-300 #c4b5fd */
--syn-builtin: 125 211 252; /* sky-300 #7dd3fc */
--syn-function: 254 240 138; /* yellow-200 #fef08a */
--syn-operator: 249 168 212; /* pink-300 #f9a8d4 */
--syn-punctuation: 156 163 175; /* gray-400 #9ca3af */
--syn-property: 103 232 249; /* cyan-300 #67e8f9 */
--syn-tag: 253 164 175; /* rose-300 #fda4af */
--syn-plain: 229 231 235; /* gray-200 #e5e7eb */
--syn-add: 110 231 183; /* emerald-300 */
--syn-del: 252 165 165; /* red-300 #fca5a5 */
} }
@layer base { @layer base {
@@ -102,12 +163,17 @@
} }
::-webkit-calendar-picker-indicator { ::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(0.8);
cursor: pointer; cursor: pointer;
opacity: 0.7; opacity: 0.7;
transition: opacity 0.2s; transition: opacity 0.2s;
} }
/* The browser draws this glyph near-black. Inverting it is a DARK-mode fix;
applied globally it painted a white icon onto a white field. */
.dark ::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(0.8);
}
::-webkit-calendar-picker-indicator:hover { ::-webkit-calendar-picker-indicator:hover {
opacity: 1; opacity: 1;
} }
+22 -19
View File
@@ -1033,46 +1033,49 @@ export function highlight(source: string, lang: string): Token[] {
/** /**
* Map a token type to a Tailwind colour class for rendering. * Map a token type to a Tailwind colour class for rendering.
* @param type A {@link TokenType} produced by {@link highlight}. * @param type A {@link TokenType} produced by {@link highlight}.
* @returns Tailwind utility classes (text colour, and a background tint for * @returns Tailwind utility classes built on the `syntax.*` THEME TOKENS
* diff add/remove lines); falls back to a neutral gray for "plain"/unknown. * (`--syn-*` in index.css), not on fixed palette steps: the dark set is the
* pastel one this file has always used, and light mode takes the same hues
* four steps darker, because a pastel keyword on a light code block is
* unreadable. Diff rows keep their faint ±10% tint, which works on both.
*/ */
export function tokenClass(type: TokenType): string { export function tokenClass(type: TokenType): string {
switch (type) { switch (type) {
case "comment": case "comment":
return "text-gray-500 italic"; // muted + italic to recede visually return "text-syntax-comment italic"; // muted + italic to recede visually
case "string": case "string":
return "text-emerald-300"; // green, shared with diff-add text return "text-syntax-string"; // green, shared with diff-add text
case "number": case "number":
return "text-orange-300"; // orange, shared with boolean literals return "text-syntax-number"; // orange, shared with boolean literals
case "keyword": case "keyword":
return "text-violet-300"; // violet, shared with diff-meta headers return "text-syntax-keyword"; // violet, shared with diff-meta headers
case "builtin": case "builtin":
return "text-sky-300"; return "text-syntax-builtin";
case "function": case "function":
return "text-yellow-200"; // yellow, shared with HTML attr names return "text-syntax-function"; // yellow, shared with HTML attr names
case "operator": case "operator":
return "text-pink-300"; return "text-syntax-operator";
case "punctuation": case "punctuation":
return "text-gray-400"; // slightly brighter than comments, dimmer than plain return "text-syntax-punctuation"; // brighter than comments, dimmer than plain
case "property": case "property":
return "text-cyan-300"; return "text-syntax-property";
case "tag": case "tag":
return "text-rose-300"; return "text-syntax-tag";
case "attr": case "attr":
return "text-yellow-200"; // matches "function" colour by design return "text-syntax-function"; // matches "function" colour by design
case "variable": case "variable":
return "text-amber-300"; return "text-syntax-function"; // amber family, same as functions/attrs
case "boolean": case "boolean":
return "text-orange-300"; // literals grouped with numbers by colour return "text-syntax-number"; // literals grouped with numbers by colour
case "diff-add": case "diff-add":
return "text-emerald-300 bg-emerald-500/10"; // green text + faint green row tint return "text-syntax-add bg-emerald-500/10"; // green text + faint green row tint
case "diff-del": case "diff-del":
return "text-red-300 bg-red-500/10"; // red text + faint red row tint return "text-syntax-del bg-red-500/10"; // red text + faint red row tint
case "diff-meta": case "diff-meta":
return "text-violet-300"; return "text-syntax-keyword";
case "plain": case "plain":
default: default:
return "text-gray-200"; // default body text colour return "text-syntax-plain"; // default body text colour
} }
} }
+54
View File
@@ -8,6 +8,43 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/**
* A status/accent FILL is pre-mixed against one fixed base, never translucent.
*
* `bg-status-success/10` used to emit `rgb(var(--status-success) / 0.1)`, which
* composites with whatever is behind it — so the one "success" chip rendered as
* five different colours over the five surface steps, and washed out badly on
* the light theme's near-white cards. Mixing against `--tint-base` (the card
* surface of the active theme) gives an OPAQUE colour: the same chip reads the
* same on the page, in a card, in a modal and inside an input row.
*
* Applied to `backgroundColor`/`borderColor` only. `text-status-warning/80` and
* `ring-accent/40` stay genuinely translucent — text and rings sit on top of a
* fill rather than replacing it — and the surface family keeps its alpha too,
* or every modal scrim would turn solid.
*/
const tint =
(variable) =>
({ opacityValue }) => {
const alpha = Number(opacityValue);
// No modifier at all: Tailwind passes `var(--tw-bg-opacity, 1)` here.
if (!Number.isFinite(alpha) || alpha >= 1) return `rgb(var(${variable}))`;
return `color-mix(in srgb, rgb(var(${variable})) ${alpha * 100}%, rgb(var(--tint-base)))`;
};
const tinted = {
accent: {
DEFAULT: tint("--accent"),
hover: tint("--accent-hover"),
muted: "color-mix(in srgb, rgb(var(--accent)) 15%, rgb(var(--tint-base)))",
},
status: {
success: tint("--status-success"),
danger: tint("--status-danger"),
warning: tint("--status-warning"),
},
};
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: "class", darkMode: "class",
@@ -37,12 +74,29 @@ export default {
secondary: "rgb(var(--fg-secondary) / <alpha-value>)", secondary: "rgb(var(--fg-secondary) / <alpha-value>)",
muted: "rgb(var(--fg-muted) / <alpha-value>)", muted: "rgb(var(--fg-muted) / <alpha-value>)",
}, },
syntax: {
comment: "rgb(var(--syn-comment) / <alpha-value>)",
string: "rgb(var(--syn-string) / <alpha-value>)",
number: "rgb(var(--syn-number) / <alpha-value>)",
keyword: "rgb(var(--syn-keyword) / <alpha-value>)",
builtin: "rgb(var(--syn-builtin) / <alpha-value>)",
function: "rgb(var(--syn-function) / <alpha-value>)",
operator: "rgb(var(--syn-operator) / <alpha-value>)",
punctuation: "rgb(var(--syn-punctuation) / <alpha-value>)",
property: "rgb(var(--syn-property) / <alpha-value>)",
tag: "rgb(var(--syn-tag) / <alpha-value>)",
plain: "rgb(var(--syn-plain) / <alpha-value>)",
add: "rgb(var(--syn-add) / <alpha-value>)",
del: "rgb(var(--syn-del) / <alpha-value>)",
},
status: { status: {
success: "rgb(var(--status-success) / <alpha-value>)", success: "rgb(var(--status-success) / <alpha-value>)",
danger: "rgb(var(--status-danger) / <alpha-value>)", danger: "rgb(var(--status-danger) / <alpha-value>)",
warning: "rgb(var(--status-warning) / <alpha-value>)", warning: "rgb(var(--status-warning) / <alpha-value>)",
}, },
}, },
backgroundColor: tinted,
borderColor: tinted,
fontFamily: { fontFamily: {
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"], sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"], mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],