ligth theme design fix

This commit is contained in:
2026-08-10 15:33:13 +05:30
parent 6cc3c9a0b7
commit 5ce580dced
34 changed files with 2399 additions and 87 deletions

View File

@@ -1,19 +1,31 @@
import Image from 'next/image';
/**
* The two official Loyaly.ai assets — and the only place in the product where
* The official Loyaly.ai assets — and the only place in the product where
* brand colour is allowed to appear. Everything around them stays monochrome.
*
* Both render at their own intrinsic ratio: a caller sizes ONE axis and the
* other is derived, so neither mark can be letterboxed or stretched. There is
* Every mark renders at its own intrinsic ratio: a caller sizes ONE axis and
* the other is derived, so nothing can be letterboxed or stretched. There is
* deliberately no `fill` / `objectFit` escape hatch — that is how logos end up
* distorted. Recolouring is likewise not a prop: the PNGs carry the mark.
*
* ── Two lockups, one component ────────────────────────────────────────────
* The wordmark is baked into the PNG, so a white one is invisible on a white
* sidebar — which is exactly what the light theme was shipping. Both files are
* rendered and the cascade picks one (see `.brand-lockup-*` in globals.css);
* the hidden variant is `display: none`, so it leaves the accessibility tree
* and the alt text is announced exactly once.
*
* Resolving this in React instead was rejected: the mode may be the literal
* 'system', which only CSS can resolve, and a client-side resolve would flash
* the wrong mark on first paint.
*/
// Intrinsic pixel dimensions of the shipped files, alpha-trimmed so the
// wordmark optically centres (the supplied original carried 44px of dead
// transparent space on its trailing edge).
const LOGO = {src: '/white-logo.png', width: 1489, height: 248};
const LOGO_DARK = {src: '/white-logo.png', width: 1489, height: 248};
const LOGO_LIGHT = {src: '/brand/loyaly-logo.png', width: 596, height: 119};
const MARK = {src: '/brand/loyaly-mark.png', width: 285, height: 256};
interface BrandProps {
@@ -23,23 +35,46 @@ interface BrandProps {
priority?: boolean;
}
/** Full horizontal Loyaly.ai lockup. Sized by height; width follows. */
/**
* Full horizontal Loyaly.ai lockup. Sized by height; width follows.
*
* Both variants are laid out at the SAME derived width, so swapping themes
* cannot shift the sidebar, the top bar or the AI panel header by a pixel.
*/
export function BrandLogo({
height = 28,
alt = 'Loyaly.ai',
priority = false,
}: BrandProps & {height?: number}) {
// Both files carry the same lockup at the same aspect ratio; deriving the
// width from each one's own intrinsics keeps that true even if a future
// asset is re-exported at a different scale.
const darkWidth = Math.round((height * LOGO_DARK.width) / LOGO_DARK.height);
const lightWidth = Math.round((height * LOGO_LIGHT.width) / LOGO_LIGHT.height);
return (
<Image
src={LOGO.src}
alt={alt}
height={height}
width={Math.round((height * LOGO.width) / LOGO.height)}
priority={priority}
// Block display keeps the anchor that usually wraps this from painting a
// hover underline in the leftover line box.
className="block"
/>
<>
{/* Dark wordmark on the light theme. The `block` display that used to
live here — keeping the wrapping anchor from painting a hover
underline in the leftover line box — is now set by the same rule
that decides visibility. */}
<Image
src={LOGO_LIGHT.src}
alt={alt}
height={height}
width={lightWidth}
priority={priority}
className="brand-lockup-light"
/>
<Image
src={LOGO_DARK.src}
alt={alt}
height={height}
width={darkWidth}
priority={priority}
className="brand-lockup-dark"
/>
</>
);
}

View File

@@ -16,6 +16,14 @@ const TONE = {
/**
* Areas fade to transparent rather than sitting as flat gray blocks — on a
* near-black surface a solid fill swallows the gridlines and the series below.
*
* The ramp is 0.20 → 0.02, down from 0.38 → 0.03. That original pair was tuned
* against the monochrome ramp, where the top stop is a mid-gray and 38% of it
* is a whisper. Footfall now draws in brand purple, and 38% of a saturated hue
* on a white card is a colour block — it read as the loudest element on the
* page while the line that carries the actual shape read as its edge. Lower
* stops put the line back in charge and leave the gridlines visible through
* the fill, which is the whole reason the fill is a gradient.
*/
export function AreaChartView<T extends object>({
data,
@@ -46,8 +54,8 @@ export function AreaChartView<T extends object>({
x2="0"
y2="1"
>
<stop offset="0%" stopColor={c} stopOpacity={0.38} />
<stop offset="100%" stopColor={c} stopOpacity={0.03} />
<stop offset="0%" stopColor={c} stopOpacity={0.2} />
<stop offset="100%" stopColor={c} stopOpacity={0.02} />
</linearGradient>
);
})}

View File

@@ -37,6 +37,20 @@ export const CHART = {
positive: 'var(--color-success)',
negative: 'var(--color-error)',
attention: 'var(--color-warning)',
/**
* The two brand accents, for the small number of marks that are allowed to
* carry the brand rather than merely a value: the two headline dashboard
* charts, the reward series, the journey bars. Opt in per series with
* `series={[{key, label, color: CHART.brand.cool}]}` — never as a default,
* or the ramp stops being monochrome and the sparklines in every KPI card
* turn purple with it. Defined in globals.css, not the Astryx theme.
*/
brand: {
warm: 'var(--color-brand-warm)',
/** Softened warm, for large filled areas — bars. See the token's note. */
warmBar: 'var(--color-brand-warm-bar)',
cool: 'var(--color-brand-cool)',
},
} as const;
/** The non-chromatic channel. */

View File

@@ -0,0 +1,37 @@
/**
* The two-accent brand system, expressed as class names.
*
* There are exactly two non-semantic hues in this product — warm (Loyaly
* yellow) and cool (purple) — and their values live in globals.css as
* `--color-brand-*`. This file is the only place that turns one into markup,
* so "which utility do I use for a warm icon?" has a single answer and a
* feature file never writes `text-brand-warm-ink` by hand.
*
* Assignment is a property of the DATA, not of the position in a grid. Each
* activity carries its own accent from the API, so the same activity is the
* same colour on the dashboard, on the activity page and inside a campaign
* row — and a grid that gains a row does not repaint itself.
*
* `ink` is the readable slot (icons, text); `soft` is a tint for an icon chip
* only. Nothing here paints a card background: the cards stay white.
*/
export type BrandAccent = 'warm' | 'cool';
export const ACCENT: Record<
BrandAccent,
{ink: string; soft: string; solid: string; chip: string}
> = {
warm: {
ink: 'text-brand-warm-ink',
soft: 'bg-brand-warm-soft',
solid: 'bg-brand-warm',
chip: 'bg-brand-warm-soft text-brand-warm-ink',
},
cool: {
ink: 'text-brand-cool-ink',
soft: 'bg-brand-cool-soft',
solid: 'bg-brand-cool',
chip: 'bg-brand-cool-soft text-brand-cool-ink',
},
};

View File

@@ -11,11 +11,14 @@
*/
import {
ArrowRight,
BadgeCheck,
Bell,
BookOpen,
Building2,
CalendarClock,
CalendarDays,
Camera,
ChartLine,
ChevronsUpDown,
CircleAlert,
@@ -25,6 +28,8 @@ import {
Coins,
Columns3,
CreditCard,
Disc3,
DoorOpen,
Download,
Expand,
Footprints,
@@ -35,9 +40,11 @@ import {
Info,
Keyboard,
LayoutDashboard,
Lightbulb,
Lock,
LogOut,
Maximize2,
Megaphone,
Menu,
MessageSquare,
Mic,
@@ -50,21 +57,26 @@ import {
Plus,
Percent,
Plug,
Route,
Send,
Settings,
ShieldCheck,
Shrink,
ShoppingBag,
ShoppingCart,
Sliders,
Square,
SquarePen,
Sparkles,
Store,
Target,
Ticket,
TrendingDown,
TrendingUp,
Trophy,
User,
UserCheck,
UserPlus,
Users,
UserX,
} from 'lucide-react';
@@ -100,6 +112,30 @@ export const ICONS = {
conversion: Percent,
lyt: Coins,
// Customer activities.
//
// One glyph per activity in the loyalty ecosystem. Named with an `activity`
// prefix rather than folded into the metric block above because several
// collide with an existing concept and must not be merged with it: a `visit`
// is a customer walking in, which is not the `visitors` KPI; `activityShop`
// is the shop ACTIVITY, while `purchases` is the transaction metric.
activityWalk: Footprints,
activityVisit: DoorOpen,
activitySelfie: Camera,
activitySpin: Disc3,
activityScratch: Ticket,
activityBrand: Megaphone,
activityChallenge: Target,
activityFriend: UserPlus,
activityShop: ShoppingBag,
activityEvent: CalendarDays,
// Store intelligence
journey: Route,
campaign: Megaphone,
insight: Lightbulb,
arrowRight: ArrowRight,
// Dashboard controls
compare: Columns3,