Authoritative styling guide for building new pages, components, and UI in this codebase. Follow these rules exactly so new screens look and feel like the rest of the product.
The product visual identity is Editorial / Vietnamese Wall Street Journal — restrained typography, dotted/dashed dividers, hairline borders, generous whitespace, square-ish corners (≤ 4 px on cards, 0 on most controls), and accent color used sparingly. Avoid "SaaS-y" rounded pills, gradients, and deep shadows.
The app supports light (default) and dark themes. Themes are switched by setting data-theme="light|dark" on <html>. A bootstrap script in nuxt.config.ts reads the theme cookie and applies the attribute before first paint to prevent FOUC.
Always use CSS variables. Never hardcode colors in components — read from assets/theme.css so both themes work automatically.
Defined in assets/theme.css. All component CSS must consume these variables.
| Token | Light | Dark | Usage |
|---|---|---|---|
--bg-color |
#ffffff |
#1a1a1a |
Page background |
--text-color / --text-primary |
#1a1a1a |
#ffffff |
Primary text, headings |
--text-secondary |
#666 |
#b3b3b3 |
Body, meta, labels |
--text-tertiary |
#999 |
#888 |
Hints, dates, tertiary |
--text-tab |
#fff |
#000 |
Text on dark accent fills |
--border-color |
#e1e1e1 |
#333 |
Default borders, dividers |
--border-light |
#e5e5e5 |
#444 |
Lighter borders |
--border-lighter |
#f5f5f5 |
#2a2a2a |
Very subtle separators |
--input-bg |
#f8f9fa |
#2a2a2a |
Inputs, hover backgrounds |
--card-bg |
#ffffff |
#222 |
Card surfaces |
--card-border |
1px solid #e5e5e5 |
none |
Whole shorthand for card border (light only) |
--surface-1 |
#ffffff |
#2a2a2a |
Surface level 1 |
--surface-2 |
#f8f9fa |
#333 |
Surface level 2 (footer, headers) |
--surface-3 |
#f0f0f0 |
#3a3a3a |
Surface level 3 |
--surface-bg |
#ffffff |
#1a1a1a |
Generic surface |
--hover-bg |
#f0f1f2 |
#3a3a3a |
Row/control hover |
--tab-bg |
#f8f9fa |
#2a2a2a |
Inactive tab background |
--tab-hover-bg |
#f0f1f2 |
#3a3a3a |
Tab hover |
--primary-color |
#374151 (gray-700) |
#d1d5db (gray-300) |
Primary brand action — neutral! |
--primary-hover |
#1f2937 |
#f3f4f6 |
Primary hover |
--primary-light-bg |
rgba(55, 65, 81, 0.08) |
rgba(209, 213, 219, 0.1) |
Primary tinted background |
--primary-rgb |
55, 65, 81 |
209, 213, 219 |
RGB form for rgba() |
--accent-color |
#10b981 (emerald) |
#10b981 |
The single accent — brand dot, underlines, hover edges, "live" highlights |
--positive-bg / --positive-color |
#e8f5e8 / #2d7d32 |
#1a3d1a / #4caf50 |
Gains, up moves |
--negative-bg / --negative-color |
#fee / #c33 |
#3d1a1a / #f44336 |
Losses, errors |
--warning-bg / --warning-color / --warning-border |
#fff3cd / #856404 / #ffeaa7 |
#3d3a1a / #ffc107 / #8b7c00 |
Warnings |
--spinner-bg |
#e1e1e1 |
#333 |
Spinner ring |
--overlay-background |
rgba(0,0,0,0.5) |
rgba(0,0,0,0.5) |
Modal scrims |
Important: --primary-color is intentionally a neutral dark gray, NOT a vibrant brand color. The only colorful accent in the editorial palette is --accent-color (emerald #10b981). Use it for small highlights — brand dot, underline indicators, "active" left-border on dropdowns, hover border on cards.
Used in market data, badges, status pills:
- Positive / up:
var(--positive-color)onvar(--positive-bg) - Negative / down:
var(--negative-color)onvar(--negative-bg) - Warning:
var(--warning-color)onvar(--warning-bg)withvar(--warning-border) - "Live" pulse dot:
#ef4444(red, 6×6 px circle,pulse-dot 2sanimation)
Tier -1 Admin → #8b5cf6 (purple)
Tier -2 Content → #14b8a6 (teal)
Tier 3 Max → #f59e0b (amber) + animated "fire" gradient on text tags
Tier 2 Pro → #3b82f6 (blue)
Tier 1 Plus → #10b981 (green)
Tier 0 Free → #6b7280 (gray)
Fonts are loaded as a single Google Fonts request in assets/theme.css.
| Font family | Use for |
|---|---|
'Be Vietnam Pro', sans-serif (400/500/600/700) |
Body text, post titles, UI labels, section titles — Vietnamese-first sans-serif |
'Andada Pro', Georgia, serif (700) |
Editorial branding — logo, brand name, user name, mobile login button — uppercase, wide tracking |
'Inter', ui-sans-serif, system-ui, sans-serif (400/500/600/700) |
Default global font (set on *), used for nav items, buttons, generic UI |
'Playfair Display', serif (italic 600/700) |
Reserved for editorial pull-quotes / hero display where used |
| Role | Size | Weight | Family | Notes |
|---|---|---|---|---|
| Logo / brand | 22 px | 700 | Andada Pro | uppercase, letter-spacing: 0.07em |
| Featured post title (hero) | 1.7 rem (~27 px) | 700 | Be Vietnam Pro | line-height 40 px, clamp 3 lines |
| Page H1 / hero | 1.5 – 2 rem | 700 | Be Vietnam Pro | |
| Section title (eyebrow) | 0.7 rem | 700 | Be Vietnam Pro | uppercase, letter-spacing: 0.15em, with leading 18×3 px accent bar |
| Card title | 1 rem | 700 | Be Vietnam Pro | line-height 1.35, clamp 2 lines |
| Nav item | 0.875 rem | 500 (active 600) | Inter | letter-spacing: 0.02em |
| Body | 0.875 – 0.9 rem | 400 | Be Vietnam Pro / Inter | line-height 1.4 – 1.65 |
| Meta / dates | 0.68 – 0.78 rem | 400/500 | Be Vietnam Pro | letter-spacing: 0.02em |
| Micro label / control | 0.72 rem | 600 | Inter | uppercase, letter-spacing: 0.08-0.09em |
| Tag / pill | 0.6 – 0.65 rem | 700 | Inter | uppercase, letter-spacing: 0.05-0.1em |
Use font-variant-numeric: tabular-nums; letter-spacing: -0.02em; for any monetary or financial figure so columns line up.
- Default
<html lang="vi">. Keep all copy translatable via$t('key'); add Vietnamese (i18n/vi.json) AND English (i18n/en.json). - Vietnamese diacritics need adequate line-height — use
line-height: 1.4minimum for body,1.35for tight titles. - Do NOT use
text-transform: uppercaseon Vietnamese long-form copy (loses diacritics legibility); reserve uppercase for Latin labels, eyebrows, brand.
Outer page max width : 1600 px (default layout, navbar)
Editorial content max : 1200 px (footer, article contexts)
Page padding : 20 px desktop → 16 px ≤768 px → 12 px ≤480 px
Navbar height : 72 px desktop, 60 px mobile
layouts/default.vue wraps everything: <Navbar /> <main class="main-content"><slot /></main> <Footer /> <ToastNotifications />.
Use multiples of 4 px. Common values you'll see throughout:
4 / 6 / 8 / 10 / 12 / 14 / 16 / 18 / 20 / 24 / 28 / 32 / 40 / 52 / 56 / 60 / 64 px
Sections separate by 52–60 px bottom margin. Cards group with 20 px gap. Inside cards: 14–20 px padding.
- Navbar: 3-col grid
1fr 2fr 1fr(left logo / center nav+search / right user) - Home hero: 2-col
1fr 2fr(secondary stack + featured) - Section grids:
repeat(3, 1fr)with20pxgap, collapse to 2 then 1 column - Content + sidebar:
2.2fr 0.8frwith18pxgap (defined inlayouts/default.vueas.content-grid); collapses to single column ≤1024 px - Footer:
340px 1frwith64pxgap; nav inside footer:1fr 1fr 1.4fr
Tablet : ≤ 1024 px
Mobile : ≤ 768 px
Small phone: ≤ 480 px
Always include mobile styles in the same component file.
This is what makes the design feel editorial. Get this wrong and it looks like generic SaaS.
0 → buttons, inputs, dropdown menus, mobile actions, navbar, tabs (default)
2 px → small "see all" pill chips
4 px → post cards, image thumbnails, content cards
6 px → dropdown menu items (only the inner item, not the menu)
8 px → newsletter input wrapper, error/info banners
12 px → financial-table-wrapper on mobile
16 px → financial-table-wrapper on desktop, paywall card
50% → live-badge dot, brand dot, social link circles, spinner
Default to 0 or 4 px. Reserve large radii for data-heavy components like the financial table.
- Hairline
1px solid var(--border-color)everywhere by default. - Dashed
1px dashed var(--border-color)for editorial dividers (footer top, mobile nav primary section, mobile login button, mobile action buttons). - Section titles use a 18×3 px solid
var(--accent-color)bar as a::beforepseudo-element rather than a border. - Cards use a 3 px left border in
var(--border-color)that turnsvar(--accent-color)on hover (secondary-post-card,featured-post-cardpattern). - Dropdowns: top 2 px accent border (
border-top: 2px solid var(--primary-color)). - Dropdown items: 2 px transparent left border that becomes
var(--primary-color)on hover/active.
Use sparingly. The navbar itself has no shadow — it uses a gradient hairline ::after instead.
/* Card resting (hover only) */
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1);
[data-theme="dark"]: 0 12px 32px rgba(0, 0, 0, 0.45);
/* Dropdown menu */
box-shadow: 0 2px 8px rgba(0,0,0,0.06), 0 12px 40px rgba(0,0,0,0.14);
[data-theme="dark"]: 0 2px 8px rgba(0,0,0,0.25), 0 12px 40px rgba(0,0,0,0.6);
/* Financial table wrapper */
box-shadow: 0 4px 16px rgba(0,0,0,0.06); hover: 0 8px 24px rgba(0,0,0,0.08);
/* Paywall card */
box-shadow: 0 20px 40px rgba(0,0,0,0.15);Avoid drop shadows on flat surfaces or buttons. Avoid colored shadows.
Used on the navbar (backdrop-filter: blur(10px) on rgba(255,255,255,0.95) light / rgba(14,13,12,0.96) dark) and on the paywall card (blur(20px)). Don't overuse.
There is no global button class. Re-create the pattern each time using these recipes:
Primary (rare — prefer text/underline)
background: var(--text-color);
color: var(--bg-color);
border: 1px solid var(--text-color);
border-radius: 0;
padding: 10–14px 16–20px;
font-weight: 600;
text-transform: uppercase; /* if Latin */
letter-spacing: 0.08em;
transition: background 0.2s ease, color 0.2s ease;
/* hover: invert — background var(--bg-color), color var(--text-color) */Outline / Login button
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-color);
border-radius: 0;
padding: 7px 14px;
min-height: 36px;
/* hover: background var(--input-bg), border-color var(--primary-color),
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.08) */Pill chip / "See all"
background: transparent;
border: 1px solid var(--border-color);
border-radius: 2px;
padding: 5px 12px;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
/* hover: background var(--accent-color), color #fff, border-color var(--accent-color) */Tab button — bottom-border driven, no fill. Active state = bold text + 2 px var(--primary-color) underline.
Icon-only / link-style — background: none; border: none; color: var(--text-secondary), hover → var(--text-color). Use for nav items.
The signature interaction. Never use background fills on nav items:
.nav-item {
background: none; border: none; border-radius: 0;
color: var(--text-secondary);
font-size: 0.875rem; font-weight: 500;
letter-spacing: 0.02em;
padding: 10px 14px;
position: relative;
}
.nav-item::after { /* animated underline */
content: ''; position: absolute;
bottom: 5px; left: 14px; right: 14px; height: 1.5px;
background: var(--primary-color);
transform: scaleX(0); transform-origin: left;
transition: transform 0.22s cubic-bezier(0.4,0,0.2,1), opacity 0.22s ease;
opacity: 0;
}
.nav-item:hover::after { transform: scaleX(1); opacity: 0.35; }
.nav-item.active { color: var(--text-color); font-weight: 600; }
.nav-item.active::after { transform: scaleX(1); opacity: 1; height: 2px; }background: var(--card-bg);
border: var(--card-border); /* shorthand var, dark theme = none */
border-left: 3px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
transition: transform 0.35s cubic-bezier(0.25,0.46,0.45,0.94),
box-shadow 0.35s cubic-bezier(0.25,0.46,0.45,0.94),
border-color 0.3s ease;
/* hover */
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0,0,0,0.1); /* dark: 0.45 alpha */
border-left-color: var(--accent-color);Image inside card: object-fit: cover, animates scale(1.06) over 0.55 s on card hover.
position: absolute;
top: calc(100% + 12px); /* or 0 for nav center menus */
background: var(--card-bg);
border: 1px solid var(--border-color);
border-top: 2px solid var(--primary-color); /* signature accent strip */
border-radius: 0; /* squared */
box-shadow: 0 2px 8px rgba(0,0,0,0.06), 0 12px 40px rgba(0,0,0,0.14);
min-width: 196–256px;
animation: dropdownFadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);Items inside: 2 px transparent border-left → var(--primary-color) on hover, background: var(--input-bg), color: var(--text-color).
background: transparent;
border: 1px solid var(--border-color);
border-radius: 8px; /* exception — soft for inputs */
padding: 10px 12px;
color: var(--text-color);
font-size: 13px;
outline: none;
/* :focus-within on wrapper */
border-color: var(--accent-color);
/* placeholder */
color: var(--text-tertiary);Use the shared classes from assets/css/financial-tables.css:
.financial-table-wrapper, .financial-table-container, .financial-table, .financial-row, .metric-cell, .value-cell, .value-display, .value-main, .value-suffix, .section-header-row, .subsection-header-row, .total-row-style, .subtotal-row-style, .sticky-first-column.
Highlights:
- Wrapper: rounded 16 px (12 px on mobile), soft shadow, hover lifts 1 px.
- Sticky first column with
position: sticky; left: 0and contrastingbox-shadowon mobile. - Alternating row backgrounds (
var(--input-bg)30even rows). - Section header row gets a primary-color tinted gradient.
- Custom scrollbar styling included.
display: inline-flex; align-items: center;
padding: 1px 5px; border-radius: 3px;
font-size: 0.6rem; font-weight: 700;
letter-spacing: 0.05em; text-transform: uppercase;
line-height: 1.5;Tinted variants use rgba(color, 0.08) background, solid color text, rgba(color, 0.2) border. Max tier is the only animated one (fire-gradient + fire-shimmer).
<h2 class="section-title">{{ $t(...) }}</h2>.section-title {
font-size: 0.7rem; font-weight: 700;
color: var(--text-primary);
text-transform: uppercase; letter-spacing: 0.15em;
font-family: 'Be Vietnam Pro', sans-serif;
display: flex; align-items: center; gap: 10px;
}
.section-title::before {
content: ''; width: 18px; height: 3px;
background: var(--accent-color); border-radius: 2px;
}Pair with a right-aligned see-all-btn inside .section-header (flex space-between, 14 px bottom padding, 1 px solid bottom border).
- Spinners: 16/32 px circle, 2/3 px border,
var(--spinner-bg)withvar(--primary-color)top,spin 1s linear infinite. - Skeletons: shimmer animation
background: linear-gradient(90deg, var(--border-color) 25%, var(--hover-bg) 50%, var(--border-color) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite;
<ClientOnly>with skeleton fallback for any auth-dependent UI (avoid hydration flash).
.error {
background: var(--negative-bg);
color: var(--negative-color);
border: 1px solid var(--negative-color);
border-radius: 8px;
padding: 12px 16px;
text-align: center;
}Square (border-radius: 0) on the avatar itself, 32 px desktop button / 36–44 px in dropdowns. Background var(--primary-color), fallback letter is white in light theme, black in dark theme (because primary-color flips). A tier "indicator" pill sits centered below at bottom: -7px.
Scrim: var(--overlay-background) (rgba(0,0,0,0.5), dark 0.7), often with backdrop-filter: blur(2px). Z-index hierarchy:
9999 → mobile content overlay scrim
10000 → navbar, mobile menu backdrop
10001 → dropdowns, mobile nav drawer
10002 → mobile search suggestions (must escape navbar)
All easings used in the app:
| Curve | Use |
|---|---|
ease (default) |
Color/background micro-transitions |
cubic-bezier(0.4, 0, 0.2, 1) |
Underline reveal, hamburger lines |
cubic-bezier(0.25, 0.46, 0.45, 0.94) |
Card lift, image zoom |
cubic-bezier(0.16, 1, 0.3, 1) |
Dropdown / mobile-nav reveal (snappy out-spring) |
cubic-bezier(0.34, 1.56, 0.64, 1) |
Logo hover (bouncy) |
cubic-bezier(0.55, 0, 0.45, 1) |
Logo heartbeat (pageload) |
Durations: micro 0.15–0.2 s, standard 0.22 s, hover lifts 0.35 s, image zooms 0.55 s. Page transition 0.2 s opacity + translateY(8px).
Always honor reduced motion:
@media (prefers-reduced-motion: reduce) {
/* disable continuous animations like .logo-mark.is-beating */
}- Inline SVGs only. No icon font, no SVG sprite library required.
- Stroke style:
viewBox="0 0 24 24"square,stroke="currentColor",stroke-width: 1.5–2,stroke-linecap="round",stroke-linejoin="round",fill="none". - Sizes: 12 / 14 / 16 / 22 / 24 px (chevron 12, action icon 14, social/login 16, nav inline 22, hero 24).
- Brand-specific exceptions stay branded (Discord blurple
#5865F2, Vietnamese flag SVG, Facebook/YouTube usecurrentColorand inherit). - Chevron rotation pattern for "expanded" dropdown:
<svg class="dropdown-chevron" :class="{ rotated: isOpen }" ...>
.dropdown-chevron { transition: transform 0.22s cubic-bezier(0.4,0,0.2,1); opacity: 0.55; } .dropdown-chevron.rotated { transform: rotate(180deg); opacity: 0.85; }
When you add a page (pages/<route>.vue):
- Translations first — add keys to both
i18n/vi.jsonANDi18n/en.json. Never ship hard-coded UI strings. - Cache strategy — set a
routeRulesentry innuxt.config.tsmatching the page's freshness needs (defaults: editorial pages ~15 min, real-time data ≤5 min, static legal 1 hour, profile/adminno-store). - Per-page meta via
useHead/useSeoMetafortitle,description, canonical, og/twitter; the globaltitleTemplateautomatically appends- Tạp Chí Phố Wall. - Robots — Add
X-Robots-Tag: noindexfor private routes (profile,admin,paid/p,chat,support). - Locale-aware nav — Use
<NuxtLinkLocale to="...">anduseLocalePath(). Never hard-code/en/.... - Auth wrap — Wrap any auth-state UI in
<ClientOnly>with a skeleton fallback to prevent SSR flash. - Loading & empty states — Always include both. Use the spinner / skeleton patterns above; show a copy line that uses
$t(). - Page transition — Already wired globally (
pageTransition: { name: 'page', mode: 'out-in' }). Don't override unless you need to.
- The mobile navbar collapses to: left logo (with text), right
[search][lang][hamburger]. The full menu opens as a fixed dropdown below the navbar (top: 60px) with a backdrop scrim covering the page below. - Mobile drawers and submenus animate with
slideDown 0.2s easeormobileNavReveal 0.26s cubic-bezier(0.16,1,0.3,1). - Tooltips are hidden on mobile (
display: none !important≤768 px) — supply icon labels viaaria-labelinstead. - Hover effects must degrade gracefully:
@media (hover: hover) and (pointer: fine) { /* hover styles */ } @media (hover: none) and (pointer: coarse) { /* :active feedback */ }
- Tap targets min 44×44 px (search, hamburger, logo button).
- Reduce padding/sizes at 768 px and again at 480 px breakpoints — always do BOTH.
- Mobile cards use lighter shadows (
0.04alpha) and tighter radii (12 px instead of 16 px on large data wrappers).
- Every interactive element has
aria-label(icons-only) or visible text. - Dropdown triggers have
aria-expandedreflecting state. - Focus-visible outlines must remain — never
outline: nonewithout a replacement (e.g.:focus-withinaccent on input wrappers). - Color contrast ≥ 4.5:1 for body text in both themes (the tokens above are tuned for this — don't introduce custom mid-grays).
- Animations respect
prefers-reduced-motion. - Skip browser language detection — Vietnamese is the default. Don't add language popups.
- All form inputs have associated
<label>oraria-labelandplaceholderlocalised via$t().
- ❌ Hardcode hex colors in components — always use CSS variables.
- ❌ Use rounded "pill" buttons (
border-radius: 999px) — clashes with editorial vibe (exceptions: tier badges, status dots, social link circles, spinners). - ❌ Add gradients except: section-header-row tint, dropdown-header (subtle 135deg surface gradient), tier-tag-max fire animation, mobile-content-overlay, lock-icon (paywall).
- ❌ Use deep colored shadows (e.g.
box-shadow: 0 0 20px var(--accent-color)). - ❌ Apply
text-transform: uppercaseto long Vietnamese text. - ❌ Use
--primary-coloras a vibrant brand color — it's a neutral. The single accent is--accent-color. - ❌ Forget the dark-theme override block when you write hard-cased shadows or
rgba(0,0,0,...)values. - ❌ Use Tailwind utility classes — the project does not include Tailwind. Write scoped
<style>per component. - ❌ Use UI libraries (no Vuetify / PrimeVue / shadcn). Build with semantic HTML + scoped CSS.
- ❌ Add browser language detection or English defaults — Vietnamese is the default everywhere.
- Create
pages/<route>.vuewith<template>,<script setup>,<style scoped>. - Add
definePageMetaif special (e.g.prerender: true). - Add
useHead/useSeoMetafor title + description. - Add Vietnamese keys to
i18n/vi.json, English keys toi18n/en.json. - Add a
routeRulesentry innuxt.config.tsfor caching/headers. - Wrap layout: page should sit naturally inside
layouts/default.vue(1600 px max, 20 px padding). - Use the Section title pattern (
.section-title+::beforeaccent bar) for any block headings. - Compose with editorial cards (4 px radius, 3 px left border that lights up
--accent-coloron hover). - Always handle: loading (spinner / skeleton), empty (copy +
$t()), error (.errorblock). - Add mobile (≤768 px) and small-phone (≤480 px) rules in the same
<style scoped>block. - Test both themes (
data-theme="light"and="dark") — toggle via the navbarThemeToggle. - Verify
prefers-reduced-motiondoesn't break anything continuous. - Run
pnpm devand visually compare card/spacing/typography againstpages/index.vuebefore merging.
These are canonical, verified-against-source snippets. Drop them into a new page's <template> + <style scoped> and they will look native. All recipes assume the component sits inside layouts/default.vue (1600 px max, 20 px page padding).
Conventions used in every recipe
- Vue 3
<script setup>. No options API.- All copy goes through
$t('key')— define keys in bothi18n/vi.jsonandi18n/en.json.- All colors come from CSS variables in
assets/theme.css. Never hardcode.<style scoped>per-component. No Tailwind, no UI library.
The standard 2-column editorial page layout used by pages/index.vue. Use this as the starting skeleton for any content page.
<template>
<div>
<div class="main-container">
<div v-if="loading" class="page-loading">
<div class="spinner"></div>
<p class="loading-text">{{ $t('common.loading') }}</p>
</div>
<div v-else class="content-wrapper">
<div class="content-sidebar-layout">
<div class="main-content">
<!-- Primary content sections go here -->
</div>
<aside class="sidebar">
<!-- Sidebar widgets go here -->
</aside>
</div>
</div>
</div>
</div>
</template>
<script setup>
const loading = ref(false)
useHead({ title: 'Page Title' })
</script>
<style scoped>
.main-container {
max-width: 1280px;
margin: 0 auto;
padding: 28px 0 60px;
}
.content-sidebar-layout {
display: grid;
grid-template-columns: 2.2fr 0.8fr;
gap: 32px;
align-items: start;
}
.main-content { min-width: 0; }
.sidebar {
display: flex;
flex-direction: column;
gap: 32px;
position: sticky;
top: 92px;
}
.page-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 80px 0;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--spinner-bg);
border-top: 3px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text {
color: var(--text-secondary);
font-size: 0.85rem;
}
@media (max-width: 1024px) {
.content-sidebar-layout { grid-template-columns: 1fr; gap: 28px; }
.sidebar { position: static; }
}
@media (max-width: 768px) {
.main-container { padding: 20px 0 40px; }
}
</style>The signature editorial section title with leading 18×3 px accent bar and a right-aligned "see all" pill chip. Used everywhere on the homepage.
<div class="section-header">
<h2 class="section-title">{{ $t('home.latestPosts') }}</h2>
<button class="see-all-btn" @click="goToAll">{{ $t('home.viewAll') }}</button>
</div>.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border-color);
}
.section-title {
font-size: 0.7rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.15em;
font-family: 'Be Vietnam Pro', sans-serif;
display: flex;
align-items: center;
gap: 10px;
}
.section-title::before {
content: '';
display: inline-block;
width: 18px;
height: 3px;
background: var(--accent-color);
border-radius: 2px;
flex-shrink: 0;
}
.see-all-btn {
padding: 5px 12px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 2px;
color: var(--text-secondary);
font-size: 0.65rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.1em;
font-family: 'Be Vietnam Pro', sans-serif;
}
.see-all-btn:hover {
background: var(--accent-color);
color: #ffffff;
border-color: var(--accent-color);
}Sidebar variant — for narrower side widgets, use .sidebar-title (smaller, no accent bar) with a .view-all-link (text-only, no border).
.sidebar-title {
font-size: 0.66rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.15em;
font-family: 'Be Vietnam Pro', sans-serif;
}
.view-all-link {
background: none;
border: none;
color: var(--text-tertiary);
font-size: 0.63rem;
font-weight: 700;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
transition: color 0.2s ease;
font-family: 'Be Vietnam Pro', sans-serif;
padding: 0;
}
.view-all-link:hover { color: var(--accent-color); }Standard content card. 4 px radius, 3 px left border that lights to --accent-color on hover, image zooms to 1.06×. Used in the home hero left column and category grids.
<NuxtLink :to="getPostUrl(post.slug)" class="article-link">
<article class="post-card">
<div v-if="post.imageUrl" class="post-card-image">
<img :src="post.imageUrl" :alt="post.title" loading="lazy" />
</div>
<div class="post-card-content">
<h3 class="post-card-title">{{ post.title }}</h3>
<p v-if="post.description" class="post-card-description">{{ post.description }}</p>
<div class="post-card-meta">
<span class="post-card-author">{{ post.author }}</span>
<span class="meta-separator">•</span>
<time class="post-card-date">{{ formatDate(post.pubDate) }}</time>
</div>
</div>
</article>
</NuxtLink>.article-link { display: block; text-decoration: none; color: inherit; }
.post-card {
background: var(--card-bg);
border: var(--card-border);
border-left: 3px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
cursor: pointer;
display: flex;
flex-direction: column;
height: 100%;
transition:
transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94),
box-shadow 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94),
border-color 0.3s ease;
}
.post-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1);
border-left-color: var(--accent-color);
}
[data-theme="dark"] .post-card:hover {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
.post-card-image {
width: 100%;
height: 155px;
overflow: hidden;
background: var(--input-bg);
flex-shrink: 0;
}
.post-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.55s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.post-card:hover .post-card-image img { transform: scale(1.06); }
.post-card-content {
padding: 14px 16px;
flex: 1;
display: flex;
flex-direction: column;
}
.post-card-title {
font-family: 'Be Vietnam Pro', sans-serif;
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.35;
margin: 0 0 auto 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-card-description {
font-size: 0.82rem;
color: var(--text-secondary);
line-height: 1.5;
margin: 8px 0 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-card-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.68rem;
color: var(--text-secondary);
margin-top: 12px;
font-family: 'Be Vietnam Pro', sans-serif;
letter-spacing: 0.02em;
}
.post-card-author { font-weight: 500; color: var(--accent-color); }
.post-card-date { color: var(--text-tertiary); }
.meta-separator { color: var(--text-tertiary); }For the lead post in a hero grid. Same structure as R3 but with bigger image (280 px) and larger title.
.featured-post-card {
background: var(--card-bg);
border: var(--card-border);
border-left: 3px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
cursor: pointer;
min-height: 460px;
height: 100%;
display: flex;
flex-direction: column;
transition:
transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94),
box-shadow 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94),
border-color 0.3s ease;
}
.featured-post-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1);
border-left-color: var(--accent-color);
}
.featured-post-image { width: 100%; height: 280px; overflow: hidden; background: var(--input-bg); }
.featured-post-image img {
width: 100%; height: 100%; object-fit: cover;
transition: transform 0.55s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.featured-post-card:hover .featured-post-image img { transform: scale(1.06); }
.featured-post-content { padding: 18px 20px; flex: 1; display: flex; flex-direction: column; justify-content: center; }
.featured-post-title {
font-family: 'Be Vietnam Pro', sans-serif;
font-size: 1.7rem;
font-weight: 700;
line-height: 40px;
color: var(--text-primary);
margin: 0 0 8px 0;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}Hero grid pairing (1 featured + 2 secondary stacked):
<div class="hero-posts-grid">
<div class="secondary-posts-stack">
<!-- 2× R3 post cards -->
</div>
<!-- R4 featured post card -->
</div>.hero-posts-grid { display: grid; grid-template-columns: 1fr 2fr; gap: 20px; }
.hero-posts-grid > .article-link { display: block; min-height: 460px; }
.secondary-posts-stack { display: flex; flex-direction: column; gap: 20px; height: 100%; }
@media (max-width: 768px) {
.hero-posts-grid { grid-template-columns: 1fr; }
}Horizontal compact card with 70 px square thumb. Used for "Most Popular" and similar sidebar lists. Hairline divider between items, no background, pad-on-hover micro-interaction.
<div class="popular-posts-list">
<NuxtLink v-for="post in posts" :key="post.id" :to="getPostUrl(post.slug)" class="article-link">
<article class="popular-post-card">
<div v-if="post.imageUrl" class="popular-post-image">
<img :src="post.imageUrl" :alt="post.title" loading="lazy" />
</div>
<div class="popular-post-content">
<h4 class="popular-post-title">{{ post.title }}</h4>
<div class="popular-post-meta">
<time class="popular-post-date">{{ formatDateShort(post.pubDate) }}</time>
<span class="meta-separator">•</span>
<span class="popular-post-author">{{ post.author }}</span>
</div>
</div>
</article>
</NuxtLink>
</div>.popular-posts-list { display: flex; flex-direction: column; }
.popular-post-card {
display: flex;
gap: 12px;
cursor: pointer;
padding: 13px 0;
border-bottom: 1px solid var(--border-lighter);
align-items: flex-start;
}
.popular-posts-list .article-link:last-child .popular-post-card { border-bottom: none; }
.popular-post-image {
width: 70px;
height: 70px;
border-radius: 2px;
overflow: hidden;
background: var(--input-bg);
flex-shrink: 0;
}
.popular-post-image img {
width: 100%; height: 100%; object-fit: cover;
transition: transform 0.4s ease;
}
.popular-post-card:hover .popular-post-image img { transform: scale(1.06); }
.popular-post-content { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.popular-post-title {
font-family: 'Be Vietnam Pro', sans-serif;
font-size: 0.88rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
margin: 0 0 6px 0;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.popular-post-meta {
display: flex; align-items: center; gap: 5px;
font-size: 0.63rem;
color: var(--text-tertiary);
font-family: 'Be Vietnam Pro', sans-serif;
letter-spacing: 0.02em;
}
.popular-post-date { color: var(--text-tertiary); }
.popular-post-author { font-weight: 500; color: var(--accent-color); }Used for tab switchers like "7 days / 30 days / 90 days". No backgrounds, just a 2 px accent underline on the active tab and a 1 px border line under the whole bar.
<div class="tab-bar">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-btn"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
{{ $t(tab.labelKey) }}
</button>
</div>.tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-color);
margin-bottom: 4px;
}
.tab-btn {
padding: 8px 14px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-size: 0.62rem;
font-weight: 700;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: 'Be Vietnam Pro', sans-serif;
}
.tab-btn:hover { color: var(--text-primary); }
.tab-btn.active {
color: var(--accent-color);
border-bottom-color: var(--accent-color);
}The accent-top, square dropdown used everywhere in the navbar, user menu, login menu. Always paired with a trigger button and a click-outside handler.
<div class="dropdown-container">
<button
class="dropdown-trigger"
:class="{ active: isOpen }"
:aria-expanded="isOpen"
@click="isOpen = !isOpen"
>
{{ $t('label') }}
<svg class="dropdown-chevron" :class="{ rotated: isOpen }"
width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="isOpen" class="dropdown-menu">
<NuxtLinkLocale
v-for="item in items"
:key="item.to"
:to="item.to"
class="dropdown-item"
:class="{ active: route.path === item.to }"
@click="isOpen = false"
>
{{ $t(item.labelKey) }}
</NuxtLinkLocale>
</div>
</div><script setup>
const isOpen = ref(false)
const route = useRoute()
onMounted(() => {
document.addEventListener('click', (e) => {
if (!e.target.closest('.dropdown-container')) isOpen.value = false
})
})
</script>.dropdown-container { position: relative; display: inline-flex; }
.dropdown-trigger {
display: flex; align-items: center; gap: 4px;
padding: 10px 14px;
background: none; border: none;
color: var(--text-secondary);
font-size: 0.875rem; font-weight: 500;
cursor: pointer;
transition: color 0.18s ease;
}
.dropdown-trigger:hover, .dropdown-trigger.active { color: var(--text-color); }
.dropdown-chevron {
transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0.55;
}
.dropdown-chevron.rotated { transform: rotate(180deg); opacity: 0.85; }
.dropdown-menu {
position: absolute;
top: calc(100% + 0px);
left: 50%;
transform: translateX(-50%);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-top: 2px solid var(--primary-color);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.06),
0 12px 40px rgba(0, 0, 0, 0.14);
min-width: 200px;
z-index: 10001;
overflow: hidden;
animation: dropdownFadeInCentered 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
[data-theme="dark"] .dropdown-menu {
border-color: rgba(255, 255, 255, 0.08);
border-top-color: var(--primary-color);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.25),
0 12px 40px rgba(0, 0, 0, 0.6);
}
@keyframes dropdownFadeInCentered {
from { opacity: 0; transform: translateX(-50%) translateY(-4px) scale(0.985); }
to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
}
.dropdown-item {
display: flex; align-items: center;
padding: 11px 16px 11px 14px;
background: none; border: none;
border-left: 2px solid transparent;
color: var(--text-secondary);
font-size: 0.875rem; font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
white-space: nowrap;
width: 100%;
text-align: left;
text-decoration: none;
}
.dropdown-item:hover {
background: var(--input-bg);
color: var(--text-color);
border-left-color: var(--primary-color);
}
.dropdown-item.active {
background: var(--input-bg);
color: var(--text-color);
font-weight: 600;
border-left-color: var(--primary-color);
}Right-aligned variant (user menu): replace
left: 50%; transform: translateX(-50%);withright: 0;and use the non-centereddropdownFadeInkeyframe (from { transform: translateY(-4px) scale(0.985); }).
Centered overlay with scrim + backdrop blur, square card, accent-top border, fade-in animation. Always wrap in <ClientOnly> and close on Escape + scrim click.
<template>
<ClientOnly>
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="modal-overlay" @click.self="$emit('close')" @keydown.esc="$emit('close')" tabindex="-1">
<div class="modal-card" role="dialog" aria-modal="true">
<button class="modal-close" :aria-label="$t('common.close')" @click="$emit('close')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
<h2 class="modal-title">{{ $t('modal.title') }}</h2>
<p class="modal-description">{{ $t('modal.description') }}</p>
<slot />
</div>
</div>
</Transition>
</Teleport>
</ClientOnly>
</template>
<script setup>
defineProps({ show: Boolean })
defineEmits(['close'])
</script>.modal-overlay {
position: fixed; inset: 0;
background: var(--overlay-background);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
z-index: 10005;
padding: 20px;
}
[data-theme="dark"] .modal-overlay { background: rgba(0, 0, 0, 0.7); }
.modal-card {
position: relative;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-top: 2px solid var(--primary-color);
border-radius: 4px;
padding: 32px;
max-width: 480px;
width: 100%;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}
[data-theme="dark"] .modal-card { box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); }
.modal-close {
position: absolute; top: 12px; right: 12px;
width: 32px; height: 32px;
background: none; border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
border-radius: 0;
transition: color 0.18s ease, background 0.18s ease;
}
.modal-close:hover { color: var(--text-color); background: var(--input-bg); }
.modal-title {
font-family: 'Be Vietnam Pro', sans-serif;
font-size: 1.25rem; font-weight: 700;
color: var(--text-primary);
margin: 0 0 8px 0;
}
.modal-description {
font-size: 0.9rem;
color: var(--text-secondary);
line-height: 1.5;
margin: 0 0 20px 0;
}
.modal-enter-active, .modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active .modal-card, .modal-leave-active .modal-card {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.modal-enter-from, .modal-leave-to { opacity: 0; }
.modal-enter-from .modal-card, .modal-leave-to .modal-card { transform: translateY(-8px) scale(0.98); }
</style>There is no input library — write each. All share: 1 px border, focus ring uses var(--accent-color), placeholder uses var(--text-tertiary), padding 10–12 px.
<div class="form-field">
<label class="form-label" for="email">{{ $t('form.email') }}</label>
<input
id="email"
v-model="email"
type="email"
class="form-input"
:placeholder="$t('form.emailPlaceholder')"
/>
<p v-if="error" class="form-error">{{ error }}</p>
</div>.form-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
.form-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.form-input,
.form-textarea,
.form-select {
background: transparent;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 12px;
color: var(--text-color);
font-size: 0.875rem;
font-family: inherit;
outline: none;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
width: 100%;
}
.form-input::placeholder,
.form-textarea::placeholder { color: var(--text-tertiary); }
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
}
.form-textarea { min-height: 96px; resize: vertical; }
.form-error {
margin: 0;
font-size: 0.75rem;
color: var(--negative-color);
}<label class="form-check">
<input v-model="agreed" type="checkbox" class="form-check-input" />
<span class="form-check-box"></span>
<span class="form-check-label">{{ $t('form.agreeTerms') }}</span>
</label>.form-check {
display: inline-flex; align-items: center; gap: 10px;
cursor: pointer;
user-select: none;
font-size: 0.875rem;
color: var(--text-secondary);
}
.form-check-input {
position: absolute;
opacity: 0; pointer-events: none;
width: 1px; height: 1px;
}
.form-check-box {
width: 16px; height: 16px;
border: 1px solid var(--border-color);
border-radius: 3px;
background: var(--card-bg);
display: flex; align-items: center; justify-content: center;
transition: background 0.15s ease, border-color 0.15s ease;
flex-shrink: 0;
}
.form-check-input:checked + .form-check-box {
background: var(--accent-color);
border-color: var(--accent-color);
}
.form-check-input:checked + .form-check-box::after {
content: '';
width: 4px; height: 8px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-1px, -1px);
}
.form-check-input:focus-visible + .form-check-box {
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.18);
}
.form-check-label { color: var(--text-color); }<label class="toggle">
<input v-model="enabled" type="checkbox" class="toggle-input" />
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>.toggle { display: inline-flex; cursor: pointer; }
.toggle-input { position: absolute; opacity: 0; pointer-events: none; }
.toggle-track {
width: 36px; height: 20px;
background: var(--border-color);
border-radius: 999px;
position: relative;
transition: background 0.2s ease;
}
.toggle-thumb {
position: absolute;
top: 2px; left: 2px;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--card-bg);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.toggle-input:checked + .toggle-track { background: var(--accent-color); }
.toggle-input:checked + .toggle-track .toggle-thumb { transform: translateX(16px); }.search-wrapper {
position: relative;
display: flex; align-items: center;
}
.search-wrapper .search-icon {
position: absolute;
left: 12px;
width: 14px; height: 14px;
color: var(--text-tertiary);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 9px 12px 9px 34px;
background: var(--input-bg);
border: 1px solid transparent;
border-radius: 8px;
color: var(--text-color);
font-size: 0.875rem;
outline: none;
transition: border-color 0.18s ease, background 0.18s ease;
}
.search-input:focus {
border-color: var(--accent-color);
background: var(--card-bg);
}Always use <button type="button"> (avoid implicit form submission). All buttons share min-height 36 px, font-weight 500–700, transitions 0.18 s ease.
.btn-primary {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 10px 20px;
background: var(--text-color);
color: var(--bg-color);
border: 1px solid var(--text-color);
border-radius: 0;
font-size: 0.82rem; font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
min-height: 40px;
transition: background 0.2s ease, color 0.2s ease;
font-family: 'Be Vietnam Pro', sans-serif;
}
.btn-primary:hover {
background: var(--bg-color);
color: var(--text-color);
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }.btn-secondary {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 9px 18px;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-color);
border-radius: 0;
font-size: 0.82rem; font-weight: 600;
cursor: pointer;
min-height: 40px;
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.btn-secondary:hover {
background: var(--input-bg);
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.08);
}.btn-danger {
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 16px;
background: transparent;
color: var(--negative-color);
border: 1px solid var(--negative-color);
border-radius: 0;
font-size: 0.82rem; font-weight: 600;
cursor: pointer;
transition: background 0.18s ease;
}
.btn-danger:hover { background: rgba(239, 68, 68, 0.06); }— see R2 .see-all-btn.
.btn-text {
background: none; border: none;
color: var(--text-secondary);
font-size: 0.875rem; font-weight: 500;
cursor: pointer;
padding: 6px 8px;
transition: color 0.15s ease;
}
.btn-text:hover { color: var(--text-color); }.upgrade-btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 12px 24px;
background: var(--primary-color);
color: var(--text-tab);
border: none;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
font-size: 0.95rem;
transition: all 0.2s ease;
cursor: pointer;
}
.upgrade-btn:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}Inline pill used next to nav items, post titles, etc. to communicate plan tier or status.
<span class="tier-tag tier-tag-pro">Pro</span>
<span class="tier-tag tier-tag-max">Max</span>
<span class="tier-tag tier-tag-beta">Beta</span>.tier-tag {
display: inline-flex; align-items: center;
padding: 1px 5px;
border-radius: 3px;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
flex-shrink: 0;
line-height: 1.5;
}
.tier-tag-pro {
background: rgba(99, 102, 241, 0.08);
color: #4f46e5;
border: 1px solid rgba(99, 102, 241, 0.2);
}
[data-theme="dark"] .tier-tag-pro {
background: rgba(99, 102, 241, 0.12);
color: #818cf8;
border-color: rgba(99, 102, 241, 0.25);
}
.tier-tag-beta {
background: rgba(156, 163, 175, 0.1);
color: #9ca3af;
border: 1px solid rgba(156, 163, 175, 0.25);
}
/* Animated "fire" tag for Max tier */
.tier-tag-max {
background: linear-gradient(120deg, #b91c1c, #ea580c, #f97316, #facc15, #f97316, #ea580c, #b91c1c);
background-size: 300% 300%;
color: #fff;
border: 1px solid rgba(251, 146, 60, 0.45);
overflow: hidden;
position: relative;
animation: fire-gradient 3s ease infinite;
}
.tier-tag-max::before {
content: '';
position: absolute; top: 0; left: -80%;
width: 45%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.24), transparent);
transform: skewX(-20deg);
animation: fire-shimmer 2.8s ease-in-out infinite;
pointer-events: none;
}
@keyframes fire-gradient {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes fire-shimmer {
0% { left: -80%; }
100% { left: 180%; }
}Live indicator (used on real-time data sections):
<span class="live-indicator">
<span class="live-dot"></span>
<span class="live-text">{{ $t('common.live') }}</span>
</span>.live-indicator {
display: flex; align-items: center; gap: 5px;
padding: 2px 7px;
background: rgba(16, 185, 129, 0.1);
border-radius: 2px;
}
.live-dot {
width: 5px; height: 5px;
background: var(--accent-color);
border-radius: 50%;
animation: blink-dot 1.4s ease-in-out infinite;
}
.live-text {
font-size: 0.62rem;
font-weight: 700;
color: var(--accent-color);
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: 'Be Vietnam Pro', sans-serif;
}
@keyframes blink-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.2; }
}Standard pattern for tier-gated content. Use the shared classes from assets/css/paywall.css — they already exist; don't redefine.
<div class="access-denied-container">
<div class="blurred-preview">
<!-- Render the gated component normally; it will be blurred -->
<SomeFinancialChart :data="previewData" />
</div>
<div class="financial-paywall-overlay">
<div class="financial-paywall-card">
<div class="lock-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<h3 class="paywall-title">{{ $t('paywall.title') }}</h3>
<p class="paywall-description">{{ $t('paywall.description') }}</p>
<NuxtLinkLocale to="/pricing-plans" class="upgrade-btn">
{{ $t('paywall.cta') }}
</NuxtLinkLocale>
</div>
</div>
</div>Already-defined classes available (no need to recreate): .access-denied-container, .blurred-preview, .financial-paywall-overlay, .financial-paywall-card, .lock-icon, .paywall-title, .paywall-description, .upgrade-btn.
Sidebar items don't usually use card chrome. They share a header pattern (sidebar-title + view-all-link) and stack with hairline dividers between rows.
<section class="sidebar-block">
<div class="sidebar-block-header">
<h2 class="sidebar-title">{{ $t('search.mostPopular') }}</h2>
<button class="view-all-link" @click="goToAll">{{ $t('home.viewAll') }}</button>
</div>
<div class="sidebar-block-body">
<slot />
</div>
</section>.sidebar-block { margin-bottom: 32px; }
.sidebar-block-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 10px;
margin-bottom: 8px;
border-bottom: 1px solid var(--border-color);
position: relative;
}
.sidebar-block-header::after {
content: '';
position: absolute;
bottom: -1px; left: 0;
width: 24px; height: 2px;
background: var(--accent-color);
}
.sidebar-block-body { display: flex; flex-direction: column; }Centered, restrained — small icon (or none), short copy, optional secondary action.
<div class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
</div>
<p class="empty-title">{{ $t('common.noResultsTitle') }}</p>
<p class="empty-description">{{ $t('common.noResultsDescription') }}</p>
<button v-if="onRetry" class="btn-secondary" @click="onRetry">{{ $t('common.retry') }}</button>
</div>.empty-state {
display: flex; flex-direction: column; align-items: center;
text-align: center;
padding: 48px 24px;
gap: 8px;
color: var(--text-secondary);
}
.empty-icon {
width: 56px; height: 56px;
border-radius: 50%;
background: var(--input-bg);
display: flex; align-items: center; justify-content: center;
color: var(--text-tertiary);
margin-bottom: 8px;
}
.empty-title {
font-family: 'Be Vietnam Pro', sans-serif;
font-size: 1rem; font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.empty-description {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
margin: 0 0 12px 0;
max-width: 320px;
}Use the global .error shape; it works in both themes via tokens.
<div class="alert alert-error" role="alert">
<svg class="alert-icon" viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
</svg>
<span class="alert-message">{{ $t('errors.generic') }}</span>
</div>.alert {
display: flex; align-items: flex-start; gap: 10px;
padding: 12px 16px;
border-radius: 8px;
font-size: 0.875rem;
line-height: 1.5;
margin: 16px 0;
}
.alert-icon { flex-shrink: 0; margin-top: 1px; }
.alert-error {
background: var(--negative-bg);
color: var(--negative-color);
border: 1px solid var(--negative-color);
}
.alert-warning {
background: var(--warning-bg);
color: var(--warning-color);
border: 1px solid var(--warning-border);
}
.alert-success {
background: var(--positive-bg);
color: var(--positive-color);
border: 1px solid var(--positive-color);
}
.alert-info {
background: var(--input-bg);
color: var(--text-color);
border: 1px solid var(--border-color);
}<div class="loading-block">
<div class="spinner"></div>
<p class="loading-text">{{ $t('common.loading') }}</p>
</div>.loading-block {
display: flex; flex-direction: column; align-items: center;
gap: 12px;
padding: 40px 0;
}
.spinner,
.spinner-sm {
border-radius: 50%;
border-style: solid;
border-color: var(--spinner-bg);
border-top-color: var(--primary-color);
animation: spin 1s linear infinite;
}
.spinner { width: 32px; height: 32px; border-width: 3px; }
.spinner-sm { width: 16px; height: 16px; border-width: 2px; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text {
font-size: 0.85rem;
color: var(--text-secondary);
}<div class="skeleton skeleton-line"></div>
<div class="skeleton skeleton-card"></div>.skeleton {
background: linear-gradient(
90deg,
var(--border-color) 25%,
var(--hover-bg) 50%,
var(--border-color) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
.skeleton-line { height: 14px; width: 100%; margin-bottom: 8px; }
.skeleton-title { height: 20px; width: 70%; margin-bottom: 12px; }
.skeleton-card { height: 220px; width: 100%; }
.skeleton-avatar { width: 36px; height: 36px; border-radius: 0; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}For sections backed by real-time data (X posts, market updates). Includes a top animated stripe and polling status indicator.
<section class="live-block">
<div class="live-block-header">
<div class="live-block-title-group">
<h2 class="sidebar-title">{{ $t('home.marketUpdates') }}</h2>
<span class="live-indicator">
<span class="live-dot"></span>
<span class="live-text">{{ $t('common.live') }}</span>
</span>
</div>
<button class="view-all-link" @click="goToAll">{{ $t('home.viewAll') }}</button>
</div>
<div v-if="loading" class="loading-block"><div class="spinner-sm"></div></div>
<div v-else-if="items.length" class="live-block-list">
<article v-for="item in items" :key="item.id" class="live-update-card">
<p class="update-text" v-html="formatUpdateText(item)"></p>
<span class="update-time">{{ formatTimeAgo(item.createdAt) }}</span>
</article>
</div>
<div v-else class="empty-state">
<p class="empty-description">{{ $t('common.noUpdates') }}</p>
</div>
</section>.live-block {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.live-block::before {
content: '';
position: absolute; top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent-color), #4ade80, var(--accent-color));
background-size: 200% 100%;
animation: green-flow 3s ease-in-out infinite;
}
@keyframes green-flow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.live-block-header {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 16px;
border-bottom: 1px solid var(--border-color);
}
.live-block-title-group {
display: flex; align-items: center; gap: 10px;
}
.live-block-list { padding: 4px 16px; }
.live-update-card {
padding: 13px 0;
border-bottom: 1px solid var(--border-lighter);
transition: padding-left 0.2s ease;
cursor: pointer;
}
.live-update-card:last-child { border-bottom: none; }
.live-update-card:hover { padding-left: 8px; }
.update-text {
font-size: 0.85rem;
line-height: 1.45;
color: var(--text-primary);
margin: 0 0 5px 0;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.update-text :deep(.hashtag),
.update-text :deep(.mention) {
color: var(--accent-color);
font-weight: 600;
}
.update-time {
font-size: 0.63rem;
color: var(--text-tertiary);
font-family: 'Be Vietnam Pro', sans-serif;
letter-spacing: 0.03em;
}Square avatars (radius 0) — distinctive editorial trait. Background uses --primary-color, fallback letter flips color in dark mode.
<div class="avatar-wrapper">
<div class="avatar avatar-md">
<img v-if="user.avatarUrl" :src="user.avatarUrl" :alt="user.name" class="avatar-image" />
<div v-else class="avatar-fallback">{{ initials }}</div>
</div>
<div v-if="tier !== undefined" class="tier-indicator" :class="`tier-${tier}`">
<span class="tier-label">{{ tierLabel }}</span>
</div>
</div>.avatar-wrapper { position: relative; display: inline-flex; }
.avatar {
border-radius: 0;
background: var(--primary-color);
display: flex; align-items: center; justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.avatar-sm { width: 28px; height: 28px; }
.avatar-md { width: 36px; height: 36px; }
.avatar-lg { width: 44px; height: 44px; }
.avatar-image { width: 100%; height: 100%; object-fit: cover; }
.avatar-fallback {
color: white;
font-size: 0.8rem;
font-weight: 600;
}
[data-theme="dark"] .avatar-fallback { color: black; }
.tier-indicator {
position: absolute;
bottom: -7px; left: 50%;
transform: translateX(-50%);
height: 12px;
padding: 0 4px;
border-radius: 6px;
border: 1.5px solid var(--card-bg);
display: flex; align-items: center; justify-content: center;
z-index: 1;
}
.tier-label {
font-size: 7px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
color: white;
line-height: 1;
}
.tier-indicator.tier--1 { background: #8b5cf6; }
.tier-indicator.tier--2 { background: #14b8a6; }
.tier-indicator.tier-3 { background: #f59e0b; }
.tier-indicator.tier-2 { background: #3b82f6; }
.tier-indicator.tier-1 { background: #10b981; }
.tier-indicator.tier-0 { background: #6b7280; }The standard meta line that appears under post titles. Renders an author + date + optional stats (likes/comments) with tiny SVG icons.
<div class="meta-row">
<span class="meta-author">{{ post.author }}</span>
<span class="meta-separator">•</span>
<time class="meta-date">{{ formatDate(post.pubDate) }}</time>
<span class="meta-stats">
<span class="meta-stat" :aria-label="`${post.reactions} likes`">
<svg class="meta-stat-icon" width="11" height="11" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M7 11.8 2.7 7.6a2.55 2.55 0 0 1 0-3.6 2.55 2.55 0 0 1 3.6 0L7 4.7l.7-.7a2.55 2.55 0 0 1 3.6 0 2.55 2.55 0 0 1 0 3.6L7 11.8Z"
stroke="currentColor" stroke-width="1.15" stroke-linejoin="round" fill="none"/>
</svg>
<span>{{ formatStat(post.reactions) }}</span>
</span>
<span class="meta-stat" :aria-label="`${post.comments} comments`">
<svg class="meta-stat-icon" width="11" height="11" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M2 3.6C2 2.72 2.72 2 3.6 2h6.8c.88 0 1.6.72 1.6 1.6v4.6c0 .88-.72 1.6-1.6 1.6H6.8L4 12.2v-2.4h-.4C2.72 9.8 2 9.08 2 8.2V3.6Z"
stroke="currentColor" stroke-width="1.15" stroke-linejoin="round"/>
</svg>
<span>{{ formatStat(post.comments) }}</span>
</span>
</span>
</div>.meta-row {
display: flex; align-items: center; gap: 6px;
font-size: 0.68rem;
color: var(--text-secondary);
font-family: 'Be Vietnam Pro', sans-serif;
letter-spacing: 0.02em;
}
.meta-author { font-weight: 500; color: var(--accent-color); }
.meta-date { color: var(--text-tertiary); }
.meta-separator { color: var(--text-tertiary); }
.meta-stats {
display: inline-flex; align-items: center; gap: 10px;
margin-left: auto;
}
.meta-stat {
display: inline-flex; align-items: center; gap: 4px;
color: var(--text-tertiary);
}
.meta-stat-icon { flex-shrink: 0; }function formatStat(n) {
const v = Number(n) || 0
if (v >= 1000) return (v / 1000).toFixed(v >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'K'
return String(v)
}Drop these media queries into every component's <style scoped>. Always handle BOTH 768 px and 480 px.
/* ── Default = desktop ── */
@media (max-width: 1024px) {
/* Tablet adjustments — usually grid → fewer columns */
}
@media (max-width: 768px) {
/* Mobile — convert grids to single column,
reduce padding, hide tooltips, simplify hover effects */
}
@media (max-width: 480px) {
/* Small phone — tighter padding, smaller font sizes,
stack horizontal items vertically */
}
/* Hover degrades gracefully on touch devices */
@media (hover: hover) and (pointer: fine) {
.card:hover { /* hover styles only on desktop */ }
}
@media (hover: none) and (pointer: coarse) {
.card:active { /* tap feedback on touch */ }
}
/* Honor reduced motion */
@media (prefers-reduced-motion: reduce) {
.pulsing-thing { animation: none; }
}That's the recipe set. Combined with sections 1–11 of the design language, an agent should now be able to compose any new page that looks native to the codebase.