add website downloads and deploy workflow (#1165)

This commit is contained in:
ekko
2026-05-30 21:35:38 +08:00
committed by GitHub
parent 4c3f025b8f
commit e45b3a881e
14 changed files with 425 additions and 41 deletions
+83
View File
@@ -0,0 +1,83 @@
name: Website
on:
pull_request:
branches:
- main
- base
paths:
- packages/website/**
- packages/client/src/styles/variables.scss
- package.json
- package-lock.json
- tsconfig.website.json
- vite.config.website.ts
- .github/workflows/website-deploy.yml
release:
types:
- published
workflow_dispatch:
permissions:
contents: read
concurrency:
group: website-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
name: Build website
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Type-check website
run: npx vue-tsc -p tsconfig.website.json --noEmit
- name: Build website
run: npm run build:website
- name: Prepare SSH
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
env:
WEBSITE_SSH_KEY: ${{ secrets.WEBSITE_SSH_KEY }}
WEBSITE_SSH_KNOWN_HOSTS: ${{ secrets.WEBSITE_SSH_KNOWN_HOSTS }}
run: |
test -n "$WEBSITE_SSH_KEY"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "$WEBSITE_SSH_KEY" > ~/.ssh/website_deploy_key
chmod 600 ~/.ssh/website_deploy_key
if [ -n "$WEBSITE_SSH_KNOWN_HOSTS" ]; then
printf '%s\n' "$WEBSITE_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
fi
- name: Deploy website
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
env:
WEBSITE_SSH_USER: ${{ secrets.WEBSITE_SSH_USER }}
WEBSITE_SSH_PORT: ${{ secrets.WEBSITE_SSH_PORT }}
run: |
SSH_USER="${WEBSITE_SSH_USER:-root}"
SSH_PORT="${WEBSITE_SSH_PORT:-22}"
command -v rsync >/dev/null || {
sudo apt-get update
sudo apt-get install -y rsync
}
rsync -az --delete \
-e "ssh -i ~/.ssh/website_deploy_key -p ${SSH_PORT} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
dist/website/ \
"$SSH_USER@154.3.33.232:/var/www/ekkolearnai.com/current/"
+19 -2
View File
@@ -4,12 +4,14 @@
</p>
<p align="center">
A full-featured web dashboard for <a href="https://github.com/NousResearch/hermes-agent">Hermes Agent</a>.<br/>
A full-featured desktop app and web dashboard for <a href="https://github.com/NousResearch/hermes-agent">Hermes Agent</a>.<br/>
Manage AI chat sessions, monitor usage & costs, configure platform channels,<br/>
schedule cron jobs, browse skills — all from a clean, responsive web interface.
</p>
<p align="center">
<a href="https://github.com/EKKOLearnAI/hermes-web-ui/releases/latest">Download Hermes Studio Desktop</a>
·
<code>npm install -g hermes-web-ui && hermes-web-ui start</code>
</p>
@@ -171,7 +173,22 @@ hermes-web-ui reset-default-login
## Quick Start
### npm (Recommended)
### Desktop App (Recommended)
Download the latest **Hermes Studio** desktop installer from
[GitHub Releases](https://github.com/EKKOLearnAI/hermes-web-ui/releases/latest).
Desktop builds are published for macOS, Windows, and Linux, with separate
architecture assets where applicable. The desktop app bundles the Web UI
runtime and stores Hermes Agent data in the native Hermes location:
- Windows: `%LOCALAPPDATA%\hermes` (falls back to `%APPDATA%\hermes`)
- macOS/Linux: `~/.hermes`
The desktop wrapper stores its own Web UI state separately in
`~/.hermes-web-ui` unless `HERMES_WEB_UI_HOME` is set.
### npm
```bash
npm install -g hermes-web-ui
+18 -2
View File
@@ -4,12 +4,14 @@
</p>
<p align="center">
<a href="https://github.com/NousResearch/hermes-agent">Hermes Agent</a> 的全功能 Web 管理面板。<br/>
<a href="https://github.com/NousResearch/hermes-agent">Hermes Agent</a> 的全功能桌面应用和 Web 管理面板。<br/>
管理 AI 聊天会话、监控用量与成本、配置平台渠道、<br/>
管理定时任务、浏览技能 —— 全部在一个简洁响应式的 Web 界面中完成。
</p>
<p align="center">
<a href="https://github.com/EKKOLearnAI/hermes-web-ui/releases/latest">下载 Hermes Studio 桌面版</a>
·
<code>npm install -g hermes-web-ui && hermes-web-ui start</code>
</p>
@@ -179,7 +181,21 @@ hermes-web-ui reset-default-login
## 快速开始
### npm 安装(推荐)
### 桌面应用(推荐)
从 [GitHub Releases](https://github.com/EKKOLearnAI/hermes-web-ui/releases/latest)
下载最新的 **Hermes Studio** 桌面安装包。
桌面版会发布 macOS、Windows 和 Linux 构建;适用时会区分不同 CPU 架构。
桌面应用内置 Web UI 运行时,Hermes Agent 数据会保存到原生 Hermes 目录:
- Windows`%LOCALAPPDATA%\hermes`(找不到时回退到 `%APPDATA%\hermes`
- macOS/Linux`~/.hermes`
桌面壳自身的 Web UI 状态会单独保存到 `~/.hermes-web-ui`,除非设置了
`HERMES_WEB_UI_HOME`
### npm 安装
```bash
npm install -g hermes-web-ui
+9
View File
@@ -2,6 +2,15 @@
Electron desktop distribution for Hermes Studio.
## Install
Download the latest macOS, Windows, or Linux installer for your CPU
architecture from the project
[GitHub Releases](https://github.com/EKKOLearnAI/hermes-web-ui/releases/latest).
The desktop app bundles the Web UI runtime and launches it locally from the
native shell app.
## Data directories
Hermes Agent data is stored in the same platform-specific location as native
@@ -150,7 +150,7 @@ onMounted(() => {
<div class="install-box animate-fade-in animate-delay-3">
<code>{{ installCmd }}</code>
<button class="copy-btn" @click="copyCmd">
{{ copied ? 'Copied!' : 'Copy' }}
{{ copied ? t('ui.copied') : t('ui.copy') }}
</button>
</div>
</div>
@@ -1,11 +1,29 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useScrollReveal } from '@/composables/useScrollReveal'
const { t } = useI18n()
interface DesktopDownload {
title: string
desc: string
assetSuffix: string
}
const { t, tm } = useI18n()
useScrollReveal()
const activeTab = ref<'npm' | 'docker' | 'source'>('npm')
const activeTab = ref<'desktop' | 'npm' | 'docker' | 'source'>('desktop')
const releaseVersion = __APP_VERSION__.replace(/^v/, '')
const releaseTag = `v${releaseVersion}`
const releaseBaseUrl = 'https://github.com/EKKOLearnAI/hermes-web-ui/releases'
const releaseUrl = `${releaseBaseUrl}/tag/${releaseTag}`
const releaseDownloadUrl = `${releaseBaseUrl}/download/${releaseTag}`
const desktopDownloads = computed(() =>
(tm('install.desktop.downloads') as DesktopDownload[]).map((item) => ({
...item,
href: `${releaseDownloadUrl}/Hermes.Studio-${releaseVersion}-${item.assetSuffix}`,
})),
)
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
@@ -19,7 +37,7 @@ function copyText(text: string) {
<div class="install-tabs reveal">
<button
v-for="tab in (['npm', 'docker', 'source'] as const)"
v-for="tab in (['desktop', 'npm', 'docker', 'source'] as const)"
:key="tab"
class="tab-btn"
:class="{ active: activeTab === tab }"
@@ -30,7 +48,33 @@ function copyText(text: string) {
</div>
<div class="install-content reveal reveal-delay-1">
<template v-if="activeTab === 'npm'">
<template v-if="activeTab === 'desktop'">
<div class="download-list">
<a
v-for="item in desktopDownloads"
:key="item.href"
class="download-row"
:href="item.href"
target="_blank"
rel="noopener"
>
<span>
<strong>{{ item.title }}</strong>
<small>{{ item.desc }}</small>
</span>
<span class="download-action">{{ t('install.desktop.download') }}</span>
</a>
</div>
<a
class="all-downloads"
:href="releaseUrl"
target="_blank"
rel="noopener"
>
{{ t('install.desktop.allDownloads') }}
</a>
</template>
<template v-else-if="activeTab === 'npm'">
<div class="code-block" @click="copyText(t('install.npm.cmd1'))">
<code>{{ t('install.npm.cmd1') }}</code>
</div>
@@ -51,7 +95,7 @@ function copyText(text: string) {
<code>{{ t('install.source.cmd2') }}</code>
</div>
</template>
<p class="prereq">{{ t('install.prereq') }}</p>
<p class="prereq">{{ activeTab === 'desktop' ? t('install.desktop.prereq') : t('install.prereq') }}</p>
</div>
</div>
</template>
@@ -88,6 +132,8 @@ function copyText(text: string) {
background: var(--bg-secondary);
border-radius: $radius-md;
padding: 4px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab-btn {
@@ -114,6 +160,65 @@ function copyText(text: string) {
// full width within panel
}
.download-list {
display: grid;
gap: 8px;
}
.download-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
text-decoration: none;
&:first-child {
padding-top: 0;
}
&:hover .download-action {
border-color: var(--text-muted);
}
strong,
small {
display: block;
}
strong {
font-size: 15px;
font-weight: 650;
}
small {
color: var(--text-muted);
font-size: 12px;
margin-top: 3px;
}
}
.download-action {
flex: 0 0 auto;
border: 1px solid var(--border-color);
border-radius: $radius-sm;
padding: 7px 12px;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
transition: border-color $transition-fast;
}
.all-downloads {
display: inline-flex;
margin-top: 14px;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
}
.code-block {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
@@ -1,25 +1,26 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useScrollReveal } from '@/composables/useScrollReveal'
interface ScreenshotItem {
src: string
alt: string
}
const { t, tm } = useI18n()
useScrollReveal()
const images = [
{ src: '/image1.png', alt: 'AI Chat with Image Generation' },
{ src: '/image2.png', alt: 'Chat and File Browser' },
{ src: '/image3.png', alt: 'Multi-panel Workspace' },
{ src: '/image4.png', alt: 'Kanban Board' },
]
const images = computed(() => tm('screenshots.items') as ScreenshotItem[])
const activeIndex = ref(0)
let timer: ReturnType<typeof setInterval>
function next() {
activeIndex.value = (activeIndex.value + 1) % images.length
activeIndex.value = (activeIndex.value + 1) % images.value.length
}
function prev() {
activeIndex.value = (activeIndex.value - 1 + images.length) % images.length
activeIndex.value = (activeIndex.value - 1 + images.value.length) % images.value.length
}
function setActive(i: number) {
@@ -53,7 +54,7 @@ onUnmounted(() => {
<span class="dot green" />
</div>
<div class="browser-url">
<span>http://localhost:8648</span>
<span>{{ t('screenshots.localUrl') }}</span>
</div>
<div class="browser-spacer" />
</div>
@@ -71,7 +72,7 @@ onUnmounted(() => {
<!-- Navigation -->
<div class="screenshot-nav">
<button class="nav-arrow" @click="prev(); resetTimer()">
<button class="nav-arrow" :aria-label="t('screenshots.previous')" @click="prev(); resetTimer()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6" /></svg>
</button>
@@ -80,12 +81,13 @@ onUnmounted(() => {
v-for="(_img, i) in images"
:key="i"
class="dot-btn"
:aria-label="t('screenshots.goTo', { number: i + 1 })"
:class="{ active: activeIndex === i }"
@click="setActive(i)"
/>
</div>
<button class="nav-arrow" @click="next(); resetTimer()">
<button class="nav-arrow" :aria-label="t('screenshots.next')" @click="next(); resetTimer()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6" /></svg>
</button>
</div>
@@ -39,19 +39,19 @@ onMounted(async () => {
<svg viewBox="0 0 24 24" fill="currentColor" class="star-icon">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<span>Star</span>
<span>{{ t('starHistory.star') }}</span>
<span v-if="stars !== null" class="star-count">{{ stars.toLocaleString() }}</span>
</a>
<img
class="github-badge"
src="https://img.shields.io/github/license/EKKOLearnAI/hermes-web-ui?style=flat-square"
alt="License"
:alt="t('starHistory.licenseAlt')"
/>
<img
class="github-badge"
src="https://img.shields.io/github/v/release/EKKOLearnAI/hermes-web-ui?style=flat-square"
alt="Version"
:alt="t('starHistory.versionAlt')"
/>
</div>
@@ -64,7 +64,7 @@ onMounted(async () => {
>
<img
:src="chartSrc"
alt="Star History"
:alt="t('starHistory.chartAlt')"
class="chart-img"
/>
</a>
@@ -9,8 +9,8 @@ const { t } = useI18n()
<div class="footer-inner">
<div class="footer-left">
<div class="footer-brand">
<img src="/logo.png" alt="Hermes" class="footer-logo" />
<span>Hermes Web UI</span>
<img src="/logo.png" :alt="t('brand.logoAlt')" class="footer-logo" />
<span>{{ t('brand.name') }}</span>
</div>
<p class="footer-desc">{{ t('footer.description') }}</p>
</div>
@@ -30,8 +30,8 @@ function goHome() {
<header class="site-header">
<div class="header-inner">
<div class="header-left" @click="goHome">
<img src="/logo.png" alt="Hermes" class="logo-icon" />
<span class="logo-text">Hermes Web UI</span>
<img src="/logo.png" :alt="t('brand.logoAlt')" class="logo-icon" />
<span class="logo-text">{{ t('brand.name') }}</span>
</div>
<nav class="header-nav">
@@ -50,10 +50,10 @@ function goHome() {
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
<button class="icon-btn" @click="switchLocale" :title="locale === 'en' ? '中文' : 'English'">
<button class="icon-btn" @click="switchLocale" :title="locale === 'en' ? t('ui.switchToChinese') : t('ui.switchToEnglish')">
{{ locale === 'en' ? '中' : 'EN' }}
</button>
<button class="icon-btn" @click="toggleTheme" :title="isDark ? 'Light' : 'Dark'">
<button class="icon-btn" @click="toggleTheme" :title="isDark ? t('ui.lightTheme') : t('ui.darkTheme')">
<svg v-if="isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
@@ -71,7 +71,7 @@ function goHome() {
</button>
</nav>
<button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen">
<button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen" :title="t('ui.menu')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
@@ -92,7 +92,7 @@ function goHome() {
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
{{ locale === 'en' ? '中文' : 'English' }}
{{ locale === 'en' ? t('ui.switchToChinese') : t('ui.switchToEnglish') }}
</button>
<button class="mobile-action-btn" @click="toggleTheme">
<svg v-if="isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="action-icon">
@@ -109,7 +109,7 @@ function goHome() {
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="action-icon">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
{{ isDark ? 'Light Mode' : 'Dark Mode' }}
{{ isDark ? t('ui.lightMode') : t('ui.darkMode') }}
</button>
</div>
</div>
+71 -1
View File
@@ -1,4 +1,19 @@
export default {
brand: {
name: 'Hermes Web UI',
logoAlt: 'Hermes',
},
ui: {
copy: 'Copy',
copied: 'Copied!',
darkTheme: 'Dark',
lightTheme: 'Light',
darkMode: 'Dark Mode',
lightMode: 'Light Mode',
menu: 'Menu',
switchToChinese: 'Chinese',
switchToEnglish: 'English',
},
nav: {
home: 'Home',
docs: 'Documentation',
@@ -75,9 +90,59 @@ export default {
wechat: 'WeChat',
wecom: 'WeCom',
},
screenshots: {
localUrl: 'http://localhost:8648',
previous: 'Previous screenshot',
next: 'Next screenshot',
goTo: 'View screenshot {number}',
items: [
{ src: '/image1.png', alt: 'AI chat with image generation' },
{ src: '/image2.png', alt: 'Chat and file browser' },
{ src: '/image3.png', alt: 'Multi-panel workspace' },
{ src: '/image4.png', alt: 'Kanban board' },
],
},
install: {
title: 'Quick Start',
desc: 'Get Hermes Web UI running in under a minute.',
desc: 'Download the desktop app or run Hermes Web UI yourself.',
desktop: {
title: 'Desktop',
download: 'Download',
allDownloads: 'View all release assets',
prereq: 'Desktop builds bundle the Web UI runtime.',
downloads: [
{
title: 'macOS Apple Silicon',
desc: 'Apple Silicon DMG',
assetSuffix: 'arm64.dmg',
},
{
title: 'macOS Intel',
desc: 'x64 DMG',
assetSuffix: 'x64.dmg',
},
{
title: 'Windows',
desc: 'x64 installer',
assetSuffix: 'x64.exe',
},
{
title: 'Linux x64 AppImage',
desc: 'x64 AppImage',
assetSuffix: 'x86_64.AppImage',
},
{
title: 'Linux x64 Debian',
desc: 'amd64 .deb package',
assetSuffix: 'amd64.deb',
},
{
title: 'Linux arm64',
desc: 'arm64 AppImage',
assetSuffix: 'arm64.AppImage',
},
],
},
npm: {
title: 'npm',
cmd1: 'npm install -g hermes-web-ui',
@@ -97,6 +162,10 @@ export default {
starHistory: {
title: 'Growing Community',
desc: 'Star us on GitHub and join the community.',
star: 'Star',
licenseAlt: 'License',
versionAlt: 'Version',
chartAlt: 'Star History',
},
footer: {
description: 'Self-hosted AI chat dashboard for Hermes Agent.',
@@ -104,6 +173,7 @@ export default {
madeWith: 'Built with Vue 3, Naive UI, and TypeScript.',
},
docs: {
placeholder: 'Select a section from the sidebar to get started.',
sidebar: {
gettingStarted: 'Getting Started',
configuration: 'Configuration',
+71 -1
View File
@@ -1,4 +1,19 @@
export default {
brand: {
name: 'Hermes Web UI',
logoAlt: 'Hermes',
},
ui: {
copy: '复制',
copied: '已复制',
darkTheme: '深色',
lightTheme: '浅色',
darkMode: '深色模式',
lightMode: '浅色模式',
menu: '菜单',
switchToChinese: '中文',
switchToEnglish: 'English',
},
nav: {
home: '首页',
docs: '文档',
@@ -75,9 +90,59 @@ export default {
wechat: '微信',
wecom: '企业微信',
},
screenshots: {
localUrl: 'http://localhost:8648',
previous: '上一张截图',
next: '下一张截图',
goTo: '查看第 {number} 张截图',
items: [
{ src: '/image1.png', alt: '带图片生成的 AI 聊天界面' },
{ src: '/image2.png', alt: '聊天和文件浏览器界面' },
{ src: '/image3.png', alt: '多面板工作区界面' },
{ src: '/image4.png', alt: '看板管理界面' },
],
},
install: {
title: '快速开始',
desc: '一分钟内启动 Hermes Web UI。',
desc: '下载桌面应用,或自行运行 Hermes Web UI。',
desktop: {
title: '桌面版',
download: '下载',
allDownloads: '查看全部发布文件',
prereq: '桌面版已内置 Web UI 运行时。',
downloads: [
{
title: 'macOS Apple Silicon',
desc: 'Apple Silicon DMG',
assetSuffix: 'arm64.dmg',
},
{
title: 'macOS Intel',
desc: 'x64 DMG',
assetSuffix: 'x64.dmg',
},
{
title: 'Windows',
desc: 'x64 安装包',
assetSuffix: 'x64.exe',
},
{
title: 'Linux x64 AppImage',
desc: 'x64 AppImage',
assetSuffix: 'x86_64.AppImage',
},
{
title: 'Linux x64 Debian',
desc: 'amd64 .deb 安装包',
assetSuffix: 'amd64.deb',
},
{
title: 'Linux arm64',
desc: 'arm64 AppImage',
assetSuffix: 'arm64.AppImage',
},
],
},
npm: {
title: 'npm',
cmd1: 'npm install -g hermes-web-ui',
@@ -97,6 +162,10 @@ export default {
starHistory: {
title: '社区成长',
desc: '在 GitHub 上给我们加星,加入社区。',
star: '加星',
licenseAlt: '许可证',
versionAlt: '版本',
chartAlt: 'Star 历史',
},
footer: {
description: 'Hermes Agent 的自托管 AI 聊天仪表板。',
@@ -104,6 +173,7 @@ export default {
madeWith: '使用 Vue 3、Naive UI 和 TypeScript 构建。',
},
docs: {
placeholder: '从侧边栏选择一个章节开始阅读。',
sidebar: {
gettingStarted: '快速开始',
configuration: '配置说明',
+1 -1
View File
@@ -39,7 +39,7 @@ function navigate(name: string) {
<router-view />
<DocContent v-if="route.meta.page" />
<div v-else class="docs-placeholder">
<p>Select a section from the sidebar to get started.</p>
<p>{{ t('docs.placeholder') }}</p>
</div>
</div>
</div>
+13 -1
View File
@@ -9,16 +9,28 @@ import StarHistorySection from '@/components/landing/StarHistorySection.vue'
<template>
<div class="landing">
<HeroSection />
<section class="download-section">
<InstallSection />
</section>
<ScreenshotsSection />
<FeaturesGrid />
<div class="cta-row">
<InstallSection class="cta-col" />
<StarHistorySection class="cta-col" />
</div>
</div>
</template>
<style scoped lang="scss">
.download-section {
max-width: 1120px;
margin: 0 auto;
padding: 56px 24px 16px;
@media (max-width: $breakpoint-mobile) {
padding: 32px 16px 8px;
}
}
.cta-row {
max-width: 1120px;
margin: 0 auto;