Skip to content

Instantly share code, notes, and snippets.

@tuhuynh27
Last active April 30, 2026 05:00
Show Gist options
  • Select an option

  • Save tuhuynh27/f43d2065d66f62d5a50a4991af26e570 to your computer and use it in GitHub Desktop.

Select an option

Save tuhuynh27/f43d2065d66f62d5a50a4991af26e570 to your computer and use it in GitHub Desktop.
TCPW Design Language

Design Language — Tạp Chí Phố Wall

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.


1. Theme System

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.

1.1 Color tokens (CSS variables)

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.

1.2 Live-data semantic colors

Used in market data, badges, status pills:

  • Positive / up: var(--positive-color) on var(--positive-bg)
  • Negative / down: var(--negative-color) on var(--negative-bg)
  • Warning: var(--warning-color) on var(--warning-bg) with var(--warning-border)
  • "Live" pulse dot: #ef4444 (red, 6×6 px circle, pulse-dot 2s animation)

1.3 Subscription tier colors (badges only)

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)

2. Typography

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

2.1 Type scale (recommendations seen in app)

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

2.2 Numeric content

Use font-variant-numeric: tabular-nums; letter-spacing: -0.02em; for any monetary or financial figure so columns line up.

2.3 Vietnamese-first

  • 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.4 minimum for body, 1.35 for tight titles.
  • Do NOT use text-transform: uppercase on Vietnamese long-form copy (loses diacritics legibility); reserve uppercase for Latin labels, eyebrows, brand.

3. Layout & spacing

3.1 Containers

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 />.

3.2 Spacing scale

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.

3.3 Grid patterns

  • 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) with 20px gap, collapse to 2 then 1 column
  • Content + sidebar: 2.2fr 0.8fr with 18px gap (defined in layouts/default.vue as .content-grid); collapses to single column ≤1024 px
  • Footer: 340px 1fr with 64px gap; nav inside footer: 1fr 1fr 1.4fr

3.4 Breakpoints

Tablet     : ≤ 1024 px
Mobile     : ≤ 768 px
Small phone: ≤ 480 px

Always include mobile styles in the same component file.


4. Borders, corners, shadows

This is what makes the design feel editorial. Get this wrong and it looks like generic SaaS.

4.1 Border radius

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.

4.2 Borders

  • 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 ::before pseudo-element rather than a border.
  • Cards use a 3 px left border in var(--border-color) that turns var(--accent-color) on hover (secondary-post-card, featured-post-card pattern).
  • 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.

4.3 Shadows

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.

4.4 Backdrop blur

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.


5. Components & patterns

5.1 Buttons

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: 1014px 1620px;
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-stylebackground: none; border: none; color: var(--text-secondary), hover → var(--text-color). Use for nav items.

5.2 Nav items (editorial underline)

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; }

5.3 Cards (post / content)

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.

5.4 Dropdowns (menus)

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-leftvar(--primary-color) on hover, background: var(--input-bg), color: var(--text-color).

5.5 Inputs

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);

5.6 Tables (financial / data)

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: 0 and contrasting box-shadow on mobile.
  • Alternating row backgrounds (var(--input-bg)30 even rows).
  • Section header row gets a primary-color tinted gradient.
  • Custom scrollbar styling included.

5.7 Tags / badges

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).

5.8 Section title pattern

<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).

5.9 Loading states

  • Spinners: 16/32 px circle, 2/3 px border, var(--spinner-bg) with var(--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).

5.10 Errors / alerts

.error {
  background: var(--negative-bg);
  color: var(--negative-color);
  border: 1px solid var(--negative-color);
  border-radius: 8px;
  padding: 12px 16px;
  text-align: center;
}

5.11 Avatars

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.

5.12 Modal / overlay

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)

6. Motion

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 */
}

7. Iconography

  • 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 use currentColor and 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; }

8. Page rendering & SEO conventions

When you add a page (pages/<route>.vue):

  1. Translations first — add keys to both i18n/vi.json AND i18n/en.json. Never ship hard-coded UI strings.
  2. Cache strategy — set a routeRules entry in nuxt.config.ts matching the page's freshness needs (defaults: editorial pages ~15 min, real-time data ≤5 min, static legal 1 hour, profile/admin no-store).
  3. Per-page meta via useHead / useSeoMeta for title, description, canonical, og/twitter; the global titleTemplate automatically appends - Tạp Chí Phố Wall.
  4. Robots — Add X-Robots-Tag: noindex for private routes (profile, admin, paid/p, chat, support).
  5. Locale-aware nav — Use <NuxtLinkLocale to="..."> and useLocalePath(). Never hard-code /en/....
  6. Auth wrap — Wrap any auth-state UI in <ClientOnly> with a skeleton fallback to prevent SSR flash.
  7. Loading & empty states — Always include both. Use the spinner / skeleton patterns above; show a copy line that uses $t().
  8. Page transition — Already wired globally (pageTransition: { name: 'page', mode: 'out-in' }). Don't override unless you need to.

9. Mobile rules

  • 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 ease or mobileNavReveal 0.26s cubic-bezier(0.16,1,0.3,1).
  • Tooltips are hidden on mobile (display: none !important ≤768 px) — supply icon labels via aria-label instead.
  • 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.04 alpha) and tighter radii (12 px instead of 16 px on large data wrappers).

10. Accessibility checklist

  • Every interactive element has aria-label (icons-only) or visible text.
  • Dropdown triggers have aria-expanded reflecting state.
  • Focus-visible outlines must remain — never outline: none without a replacement (e.g. :focus-within accent 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> or aria-label and placeholder localised via $t().

11. Don'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: uppercase to long Vietnamese text.
  • ❌ Use --primary-color as 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.

12. Quick-start checklist for a new page

  1. Create pages/<route>.vue with <template>, <script setup>, <style scoped>.
  2. Add definePageMeta if special (e.g. prerender: true).
  3. Add useHead/useSeoMeta for title + description.
  4. Add Vietnamese keys to i18n/vi.json, English keys to i18n/en.json.
  5. Add a routeRules entry in nuxt.config.ts for caching/headers.
  6. Wrap layout: page should sit naturally inside layouts/default.vue (1600 px max, 20 px padding).
  7. Use the Section title pattern (.section-title + ::before accent bar) for any block headings.
  8. Compose with editorial cards (4 px radius, 3 px left border that lights up --accent-color on hover).
  9. Always handle: loading (spinner / skeleton), empty (copy + $t()), error (.error block).
  10. Add mobile (≤768 px) and small-phone (≤480 px) rules in the same <style scoped> block.
  11. Test both themes (data-theme="light" and ="dark") — toggle via the navbar ThemeToggle.
  12. Verify prefers-reduced-motion doesn't break anything continuous.
  13. Run pnpm dev and visually compare card/spacing/typography against pages/index.vue before merging.

Appendix: Component Recipes (copy-paste)

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 both i18n/vi.json and i18n/en.json.
  • All colors come from CSS variables in assets/theme.css. Never hardcode.
  • <style scoped> per-component. No Tailwind, no UI library.

R1. Page shell

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>

R2. Section header (eyebrow + see-all)

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); }

R3. Post card — secondary (image-on-top, compact)

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); }

R4. Post card — featured (hero, large image)

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; }
}

R5. Post card — list item (sidebar / popular)

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); }

R6. Tab bar (underline-driven, accent active)

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);
}

R7. Dropdown menu (signature editorial)

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%); with right: 0; and use the non-centered dropdownFadeIn keyframe (from { transform: translateY(-4px) scale(0.985); }).


R8. Modal / popup

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>

R9. Form fields (text input, textarea, select, checkbox, toggle)

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.

Text input

<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);
}

Checkbox / radio (custom)

<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); }

Toggle switch

<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); }

Inline icon-prefixed search input

.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);
}

R10. Buttons — full set

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.

Primary (inverted, editorial)

.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; }

Secondary (outline)

.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);
}

Danger / destructive

.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); }

Pill chip / Tag-like CTA (e.g. "see all")

— see R2 .see-all-btn.

Icon-only / link-style (text button)

.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); }

Premium upgrade button (paywall — uses primary-color fill)

.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);
}

R11. Tier tag / status badge

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; }
}

R12. Paywall (blur + overlay card)

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.


R13. Sidebar widget block (the "card-less card")

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; }

R14. Empty state

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;
}

R15. Error / alert banner

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);
}

R16. Loading states

Spinner (small + standard)

<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);
}

Skeleton (shimmer)

<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; }
}

R17. Live data block (real-time, polling status)

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;
}

R18. Avatar (square, with tier indicator)

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; }

R19. Meta row (author • date • stats)

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)
}

R20. Mobile-first responsive scaffolding

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment