Files
multica/apps/web/app/custom.css
Jiayuan Zhang 9c90327ce4 fix(ui): replace the text-transparency ladder with solid tones (MUL-5452) (#6152)
* fix(ui): replace the text-transparency ladder with solid tones (MUL-5452)

Hierarchy was being expressed with transparency: 152 call sites of
text-muted-foreground/30..80, 26 of text-foreground/60..90, plus a handful
on destructive and current, and a few written as a standalone opacity-*
utility instead of a slash alpha.

On light surfaces every muted variant failed WCAG AA - /80 reached only
3.78:1 and /40 sat at 1.80:1, below even the 3:1 floor for non-text -
because the palette had no step below --muted-foreground, so transparency
was the only tool for 'quieter than muted'.

The palette now has that step, and it is deliberately non-text:
--faint-foreground clears 3:1 (WCAG 1.4.11) on every surface for icons,
chevrons, separator glyphs and empty-cell em dashes. There is no room for
a third readable text tone - AA caps a lighter text tone 0.018 L away from
muted - so text keeps exactly one floor, --muted-foreground.

Also fixes text-destructive/70 on a cron error message, which was 3.61:1.

This branch changes zero font sizes. The sub-12px half of the issue is
MUL-5451's (#6136); keeping the two apart is what makes this one
reviewable on its own after #6108 was reverted.

apps/web/app/text-contrast.test.ts replaces muted-foreground-contrast.test.ts
rather than sitting beside it. It recomputes the floors from tokens.css
instead of hard-coding ratios, and fails the build on all four ways to spell
the defect: /70, /[0.5], /[50%], and a detached opacity-* in the same class
string. Transparency behind hover/focus/disabled stays allowed - the resting
state carries the contrast obligation and it is solid.

Co-authored-by: multica-agent <github@multica.ai>

* fix(ui): correlate transparency across a whole class expression

Review found two ways past the guard, both real.

A per-literal check cannot see cn("… text-muted-foreground", suppressed &&
"opacity-60") - one element wearing a colour in one argument and a dim in
the next. That split shape is the common one, and it was hiding live
violations: the comment trigger chips dimmed aria-pressed label text to
2.55:1 while the sweep reported clean.

The second was my own exemption. Accepting any state word within 80
characters let "text-muted-foreground hover:text-foreground opacity-50"
through, because the hover: belongs to the colour, not to the opacity.

The detector now correlates across a whole cn() call or template literal,
splits it into segments that each carry their own condition, and exempts
only on the variant prefix the opacity utility itself carries or on the
condition governing its segment. Segment splitting is what keeps
${disabled ? "opacity-60" : ""} exempt while flagging its neighbours.

Fixed what that surfaced: three trigger-chip controls (the suppressed state
is already carried by the avatar's own grayscale, the sentence wording and a
solid muted step), a disabled-skill icon chip, and the diff gutter marker.
tab-bar's isDragging is exempt - a drag ghost is an in-flight gesture, the
same category as :active.

The detector now has its own table of thirteen cases. Every hole so far has
been silent, so the shapes it must and must not catch are pinned next to the
reason each one exists.

Co-authored-by: multica-agent <github@multica.ai>

* test(ui): cover the faint tone in the cn() merge regression test

text-faint-foreground is a new text-<x> class, which is the exact shape
that silently broke the sidebar labels in #6108: tailwind-merge cannot
tell a size from a colour and drops one of them. The token does resolve
correctly today - verified both orders against a size step and against
another colour - but the test that exists to catch this was not covering
it, so the guarantee rested on nothing.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 18:27:38 +08:00

140 lines
7.0 KiB
CSS

/* =============================================================================
* Multica Web — Custom styles (non-shadcn, web-only)
* Shared styles (shiki, entrance-spin, sidebar, sonner, scrollbar) are in
* @multica/ui/styles/base.css
* ============================================================================= */
/* The landing route tree is intentionally always-light (hero/cli/cloud
* sections use hardcoded dark/light palettes). Shared components rendered
* inside (e.g. CloudWaitlistExpand on /download) use semantic tokens that
* otherwise flip to dark values under the `.dark` class set by next-themes,
* producing a palette mismatch against the hardcoded section. Re-declare
* tokens to their light values so nested token-driven components stay in
* lockstep with the surrounding design. */
.landing-light,
.landing-light * {
color-scheme: light;
}
.landing-light {
--app-shell: oklch(0.964435 0.001327 286.375);
--page-canvas: oklch(0.988087 0 0);
--surface: oklch(1 0 0);
--surface-foreground: oklch(0.141 0.005 285.823);
--surface-raised: oklch(1 0 0);
--surface-hover: oklch(0.967 0.001 286.375);
--surface-selected: oklch(0.95 0.002 286.375);
--surface-selected-foreground: oklch(0.141 0.005 285.823);
--surface-border: oklch(0.92 0.004 286.32);
--surface-shadow: 0 1px 2px rgb(15 23 42 / 0.04), 0 1px 1px rgb(15 23 42 / 0.03);
--floating-shadow: 0 16px 40px rgb(15 23 42 / 0.14), 0 3px 10px rgb(15 23 42 / 0.08);
--menu-shadow: 0 8px 24px rgb(15 23 42 / 0.08), 0 2px 6px rgb(15 23 42 / 0.05);
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
/* Kept in lockstep with the light value in tokens.css — see the AA
reasoning there before changing this number. */
--muted-foreground: oklch(0.505 0.016 285.938);
/* Same lockstep rule. Without this line the landing chevrons would inherit
the .dark value of --faint-foreground while sitting on a white surface. */
--faint-foreground: oklch(0.606 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--brand: oklch(0.55 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--success: oklch(0.55 0.16 145);
--warning: oklch(0.75 0.16 85);
--info: oklch(0.55 0.18 250);
--scrollbar-thumb: oklch(0 0 0 / 10%);
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
--scrollbar-track: transparent;
}
/* -----------------------------------------------------------------------------
* Landing editorial serif
*
* The landing tree swaps the app's `--font-serif` (Source Serif 4, set on <html>
* by app/layout.tsx) for Instrument Serif, loaded by app/(landing)/layout.tsx as
* `--font-instrument-serif`. The stack is composed here in static CSS rather
* than in next/font's `fallback` for the same reasons `--font-sans` is composed
* in app/globals.css: it has to be overridable per `<html lang>`, and it stays
* CSP-safe (no inline <style>). Keep the CJK ordering here in sync with the
* `--font-sans` chain in app/globals.css.
*
* Instrument Serif ships Latin only, so CJK headlines used to fall out of the
* stack entirely and land on the browser's default face — a sans, next to Latin
* set in a high-contrast display serif. The tail hands Han and Hangul to a
* platform Songti/Mincho/Myeongjo instead of shipping a multi-megabyte CJK
* webfont (an earlier `Noto_Serif_SC` next/font entry was never referenced by
* any rule and requested `subsets: ["latin"]`, so it downloaded no CJK glyphs).
*
* Default (en / zh / ko): Latin renders with Instrument Serif; Han falls through
* to the Chinese serifs, then Hangul to the Korean ones. Chinese MUST stay
* before Korean so zh users never get Korean Hanja glyph shapes (Hangul is a
* separate Unicode block, so ko users still get Korean fonts for Hangul). */
.landing-light {
--font-serif: var(--font-instrument-serif), "Songti SC", "SimSun",
"Noto Serif CJK SC", "Nanum Myeongjo", "AppleMyungjo", "Batang",
"Noto Serif CJK KR", ui-serif, serif;
}
/* Japanese: Kanji are Han ideographs sharing the same Unicode block as Chinese,
and CSS font-fallback order is NOT affected by `<html lang>` — so the
Chinese-first default above would hand Japanese users Chinese glyph shapes for
shared ideographs. Promote a Mincho-first chain only for Japanese. Instrument
Serif still leads for Latin; zh/ko remain as a deep fallback. */
html[lang|="ja"] .landing-light {
--font-serif: var(--font-instrument-serif), "Hiragino Mincho ProN",
"Yu Mincho", "MS Mincho", "Noto Serif CJK JP", "Songti SC", "SimSun",
"Noto Serif CJK SC", "Nanum Myeongjo", "AppleMyungjo", "Batang",
"Noto Serif CJK KR", ui-serif, serif;
}
/* Single hook for every landing surface set in the editorial serif. Declared
outside Tailwind's layers so the per-locale resets below outrank the inline
`tracking-*` / `leading-*` utilities they have to correct. */
.landing-serif {
font-family: var(--font-serif);
}
/* The landing display sizes are tuned for Latin: negative tracking and sub-1
leading, down to `tracking-[-0.038em] leading-[0.93]` at 6.4rem on the hero.
Both are wrong for CJK — Han and Hangul already fill their em box, so negative
tracking jams the glyphs together and a 0.93 line-height collides the two
lines of a `<br />`-split headline. Reset them for CJK locales only; Latin
locales keep the original treatment untouched.
`[lang|="zh"]` is the BCP-47 language-range selector: it matches exactly `zh`
or `zh-<region>` (app/layout.tsx emits `zh-CN`, `ja-JP`, `ko-KR`), never an
unrelated subtag such as `zha`. Scoped to headings so the footer wordmark — a
Latin-only <span> rendering "multica" at up to 16rem — keeps its tuned
tracking on every locale. */
html[lang|="zh"] :is(h1, h2).landing-serif,
html[lang|="ja"] :is(h1, h2).landing-serif,
html[lang|="ko"] :is(h1, h2).landing-serif {
letter-spacing: 0;
line-height: 1.25;
}
/* Korean only: Hangul is written with spaces between words, but the default
line-break rules treat every syllable as a break opportunity, so a wrapped
headline splits mid-word (모릅니 / 다). `keep-all` restores word-boundary
breaking; `break-word` is the escape hatch so a word wider than the column
still breaks instead of overflowing at narrow viewports. Chinese and Japanese
are written without spaces and break per character correctly by default. */
html[lang|="ko"] :is(h1, h2).landing-serif {
word-break: keep-all;
overflow-wrap: break-word;
}