diff --git a/.env.hn.example b/.env.hn.example
index 6aa4b16..6d6a16c 100644
--- a/.env.hn.example
+++ b/.env.hn.example
@@ -1,4 +1,5 @@
SITE_PROFILE=hn
+SITE_THEME=EZ
APP_NAME=hardnerd
APP_FULL_NAME=hardnerd.net
APP_ENV=production
diff --git a/.gitignore b/.gitignore
index 6db31ac..a4b52de 100755
--- a/.gitignore
+++ b/.gitignore
@@ -51,3 +51,5 @@ compose.split.yaml
# hn loopback development marker
/storage/framework/ez-vite.hot*
+
+/storage/framework/hardnerd-vite.hot*
diff --git a/app/Services/ThemeService.php b/app/Services/ThemeService.php
index e7f30b5..4a31e0f 100644
--- a/app/Services/ThemeService.php
+++ b/app/Services/ThemeService.php
@@ -15,7 +15,7 @@ class ThemeService
private const USER_THEME_DIR = '/storage/theme/';
private const CONFIG_FILE = 'config.json';
private const SETTING_PREFIX = 'theme_';
- private const SYSTEM_THEMES = ['Xboard', 'v2board', 'EZ'];
+ private const SYSTEM_THEMES = ['Xboard', 'v2board', 'EZ', 'Hardnerd'];
public function __construct()
{
diff --git a/app/Support/EzAssets.php b/app/Support/EzAssets.php
index ce7612a..921f100 100644
--- a/app/Support/EzAssets.php
+++ b/app/Support/EzAssets.php
@@ -9,14 +9,14 @@ final class EzAssets
{
public static function scripts(Request $request): array
{
- $origin = 'http://127.0.0.1:5174';
+ $origin = 'http://hn.loc/__hn_vite';
$marker = storage_path('framework/ez-vite.hot');
if (in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1', 'hn.loc'], true)
&& is_file($marker) && trim(file_get_contents($marker)) === $origin) {
- $socket = @fsockopen('127.0.0.1', 5174, $error, $message, 0.1);
+ $socket = @fsockopen('127.0.0.1', 5184, $error, $message, 0.1);
if ($socket) {
fclose($socket);
- return ['scripts' => [$origin.'/@vite/client', $origin.'/main.js'], 'styles' => []];
+ return ['scripts' => ['/__hn_vite/@vite/client', '/__hn_vite/main.js'], 'styles' => []];
}
}
$manifest = base_path('theme/EZ/assets/.vite/manifest.json');
diff --git a/app/Support/HardnerdAssets.php b/app/Support/HardnerdAssets.php
new file mode 100644
index 0000000..d8e2af4
--- /dev/null
+++ b/app/Support/HardnerdAssets.php
@@ -0,0 +1,30 @@
+getHost(), ['127.0.0.1', 'localhost', '::1', 'hn.loc'], true)
+ && is_file($marker) && trim(file_get_contents($marker)) === $origin) {
+ $socket = @fsockopen('127.0.0.1', 5175, $error, $message, 0.1);
+ if ($socket) {
+ fclose($socket);
+ return ['scripts' => [$origin.'/@vite/client', $origin.'/src/main.js'], 'styles' => []];
+ }
+ }
+ $manifest = base_path('theme/Hardnerd/assets/.vite/manifest.json');
+ if (!is_file($manifest)) {
+ throw new RuntimeException('Hardnerd assets missing: run npm ci && npm run build:hardnerd in frontends/.');
+ }
+ $entries = json_decode(file_get_contents($manifest), true, flags: JSON_THROW_ON_ERROR);
+ $entry = $entries['index.html'];
+ return ['scripts' => ['/theme/Hardnerd/assets/'.$entry['file']], 'styles' => array_map(fn ($file) => '/theme/Hardnerd/assets/'.$file, $entry['css'] ?? [])];
+ }
+}
diff --git a/app/Support/SiteProfile.php b/app/Support/SiteProfile.php
index ffdc922..d82188c 100644
--- a/app/Support/SiteProfile.php
+++ b/app/Support/SiteProfile.php
@@ -14,7 +14,7 @@ final class SiteProfile
public static function publicSettings(): array
{
return self::isHn() ? [
- 'frontend_theme' => 'EZ',
+ 'frontend_theme' => config('site.theme', 'EZ'),
'app_name' => config('site.name'),
'logo' => config('site.logo'),
'app_description' => config('site.description'),
diff --git a/config/site.php b/config/site.php
index a071909..12a1d0a 100644
--- a/config/site.php
+++ b/config/site.php
@@ -5,8 +5,14 @@ if (!in_array($profile, ['dpl', 'hn'], true)) {
throw new InvalidArgumentException('SITE_PROFILE must be dpl or hn.');
}
+$theme = env('SITE_THEME', 'EZ');
+if ($profile === 'hn' && !in_array($theme, ['Hardnerd', 'EZ'], true)) {
+ throw new InvalidArgumentException('hn SITE_THEME must be Hardnerd or EZ.');
+}
+
return [
'profile' => $profile,
+ 'theme' => $theme,
'name' => env('SITE_NAME', 'hardnerd'),
'full_name' => env('SITE_FULL_NAME', 'hardnerd.net'),
'logo' => env('SITE_LOGO', '/brand/hn/logo.svg'),
diff --git a/docs/maintenance/ARCHITECTURE.md b/docs/maintenance/ARCHITECTURE.md
index f28ffe9..fd8d247 100644
--- a/docs/maintenance/ARCHITECTURE.md
+++ b/docs/maintenance/ARCHITECTURE.md
@@ -12,7 +12,7 @@
## 多实例站点配置
-`SITE_PROFILE` 默认 dpl 保留原有运行行为;hn 固定 EZ 主题、独立品牌与缓存/会话命名空间。实现、子模块、开发和发布边界见 [SITE_PROFILES](SITE_PROFILES.md)。部署实例须各用各的数据库、密钥、storage 和运行资源;参数不等于自动隔离错误复用的环境。
+`SITE_PROFILE` 默认 dpl 保留原有运行行为;hn 使用独立品牌与缓存/会话命名空间,2026-10-27 起默认 EZ,保留 Hardnerd 显式选项。实现、子模块、开发和发布边界见 [SITE_PROFILES](SITE_PROFILES.md)。部署实例须各用各的数据库、密钥、storage 和运行资源;参数不等于自动隔离错误复用的环境。当前纯前端设计预览另见 [THEME_DESIGNS](THEME_DESIGNS.md),不连接本后端。
## 主要业务入口
diff --git a/docs/maintenance/SITE_PROFILES.md b/docs/maintenance/SITE_PROFILES.md
index 2fa880a..c12b54b 100644
--- a/docs/maintenance/SITE_PROFILES.md
+++ b/docs/maintenance/SITE_PROFILES.md
@@ -1,4 +1,6 @@
-# dpl / hn 独立实例与 EZ 主题
+# dpl / hn 独立实例与主题
+
+2026-10-27 用户确定 hn 先使用 `EZ`。当前独立工作树的默认值与 `.env.hn.example` 已改为 EZ,旧 Hardnerd 功能草稿保留为显式选项;已批准的 `tpl-a` 继续独立保存、不绑定 hn,见 [THEME_DESIGNS](THEME_DESIGNS.md)。本次仅修改本地配置,未合入 dpl 主项目、未推送或部署。
## 配置与边界
@@ -7,7 +9,7 @@
| 参数 | 行为 |
| --- | --- |
| 未设置或 `SITE_PROFILE=dpl` | 保持既有 APP_NAME、APP_FULL_NAME、数据库品牌设置与 frontend_theme(包括 Stellar),缓存/Redis/Horizon/session 默认命名规则不变 |
-| `SITE_PROFILE=hn` | 前台固定为 EZ,默认 hardnerd / hardnerd.net;独立命名空间 hn_cache / hn_database_ / hn_horizon: / hn_session |
+| `SITE_PROFILE=hn` | 默认 EZ,示例配置显式 `SITE_THEME=EZ`;旧 Hardnerd 草稿仅在显式 `SITE_THEME=Hardnerd` 时启用;默认 hardnerd / hardnerd.net;独立命名空间 hn_cache / hn_database_ / hn_horizon: / hn_session |
| 其他值 | 启动报错,防止拼写错误误选 dpl |
hn 可用 `SITE_NAME`、`SITE_FULL_NAME`、`SITE_DESCRIPTION`、`SITE_LOGO` 覆盖品牌;后台读到的主题/品牌与部署配置一致,不能在后台切换成另一站的主题。读取覆盖在缓存取出后、请求内合并,不写回原有设置,也不污染 Redis 中的配置。dpl 后台设置流程保持原状。
@@ -37,9 +39,25 @@ npm ci --ignore-scripts
npm run dev:hn
```
-Vite 只监听 `127.0.0.1:5174`,标记文件 `storage/framework/ez-vite.hot`。后端只在明确的本地 hostname 与运行中的该端口匹配时加载开发资源,公共 hostname 或失效标记回退到正式资源。未将 Laravel .env 注入前端。通过后端页面打开可获得实例品牌;直接打开 Vite 仅用于开发。
+固定开发入口为 `http://hn.loc/`(2026-10-27 已配置并在浏览器验收 EZ 首页)。hosts 仅映射 `127.0.0.1` / `::1`;前端 Nginx 的 `/usr/local/etc/nginx/servers/hn.loc.conf` 同样只监听双回环80。普通请求代理独立 HN PHP `127.0.0.1:8592`;`/__hn_vite/` 代理 EZ Vite `127.0.0.1:5184`,保留 WebSocket 升级。未让 root Nginx 直接读文稿、未扩大 GemCodes 书签或监听权限;此工作树仍在既有 Codex 路径,PHP CLI 直接提供 public。
-当前隔离预览:`http://127.0.0.1:8591`,工作树内部 `database/hn-preview.sqlite`,临时 Redis `127.0.0.1:6387`;这些不是正式部署地址,也不是 dpl 数据。
+Vite 标记文件 `storage/framework/ez-vite.hot` 内容为 `http://hn.loc/__hn_vite`;后端只在允许的本地 hostname、标记匹配且5184在线时加载该同源路径,公共 hostname 或失效标记仍回退正式资源。APP_URL 已设为 `http://hn.loc`,其余私密环境未变,数据库仍是 `database/hn-preview.sqlite`,Redis 仍为独立6387。未将 Laravel .env 注入前端。
+
+本次三个开发进程已启动,未新增登录项;关闭进程或重启后需重新启动。以下命令各在独立终端运行(先确认专用端口未被占用,不结束其他项目进程):
+
+```bash
+# Redis:无持久化的本地演示缓存,不使用 dpl 的 Redis
+/usr/local/bin/redis-server --bind 127.0.0.1 --port 6387 --protected-mode yes --save '' --appendonly no
+# PHP:在本工作树的 public/ 下运行,使用实际 PHP 8.4,避免 Herd 占位二进制
+cd public
+/usr/local/bin/php -S 127.0.0.1:8592 -t . ../vendor/laravel/framework/src/Illuminate/Foundation/resources/server.php
+# Vite:在本工作树根运行
+npm --prefix frontends run dev:hn
+```
+
+`npm run dev:hn` 的内部端口已由5174改为5184,避免与其他项目冲突;生产构建路径不变。不能把直接打开 Vite 或8591设计预览当作 hn.loc 的功能入口。
+
+历史 EZ/功能预览使用工作树内部 `database/hn-preview.sqlite` 和临时 Redis `127.0.0.1:6387`;这些不是 dpl 数据。2026-10-14 当前 `http://127.0.0.1:8591` 已切为纯前端设计预览,不连接该 SQLite / Redis。启动区别见 [THEME_DESIGNS](THEME_DESIGNS.md)。
## 发布与恢复
@@ -66,3 +84,35 @@ php artisan config:cache
支付/邮件既有隔离套件和订单并发套件应在独立 PHP 进程运行:前者使用精简 user schema,Eloquent 的静态 guardable columns 缓存会影响后者,合并执行可产生缺 password 的测试夹具错误。不要据此修改业务模型。
当次实际验证与尚未验收事项见 [STATUS](STATUS.md)。不将本地主题验证当作服务器上线、真实付款或客户端节点连接验收。
+
+## hn.loc 验收与回退(2026-10-27)
+
+双栈解析仅回环;首页、Vite client/main.js、品牌SVG和公开站点配置接口均200,接口品牌 hardnerd。浏览器显示 EZ 的 hardnerd 深色首页,日志确认 `[vite] connected.`,未见警告/错误。原 dpl.loc 与 home.loc 首页均200。站点回归14项/31断言、EZ配置测试1项、PHP语法和差异检查通过。没有登录账户、创建业务记录、真实付款或访问线上。
+
+备份及截图:主工作区 `codex/output/hn-local-domain-20261027/`(含私密.env备份,文件600,不提交);管理员安装备份与回执在 `/private/tmp/hn-loc-admin-20261027/`。撤回域名时只移除本次 `hn.loc.conf` 及 hosts 中带 HN 标记的两条精确映射,先 `nginx -t` 再向核实后的 master 平滑HUP;不恢复整份hosts覆盖其他项目新条目。开发文件按备份逐项比较恢复,APP_URL 改动也只恢复对应字段;不覆盖并行修改,不清理SQLite数据。域名和服务配置已本机启用,代码未提交、推送或生产发布。
+
+## HN 本地默认管理员(2026-10-27)
+
+用户要求补齐默认管理员。账号/密码按 GemDocs AI版 `ACC-005` 的统一字段契约取用,本文不保存凭据值。本次先检查发现约定账号不存在;SQLite在线备份后,使用项目官方 `XboardInstall::registerAdmin` 在 hn-preview.sqlite 中创建,用户ID3,is_admin=true、banned=false,密码哈希匹配。未覆盖旧管理员或用户、未触碰dpl/线上库。
+
+管理入口现为 `http://admin.hn.loc/`(同日按用户要求新增);原 `http://hn.loc/hn-preview-admin` 继续兼容。浏览器已确认登录界面、邮箱与密码框正常显示。本次通过该实例正常HTTP登录API获得is_admin=true,并验证管理端plan/fetch返回200、访客同一路由403;接口返回的凭据和业务内容没有输出。唯一API验收会话已撤销,未在浏览器保存登录状态或密码,未改验证码/认证策略。
+
+任务前数据库备份位于主工作区 `codex/output/hn-local-domain-20261027/hn-before-default-admin.sqlite`(600,含私有数据,不提交)。默认账号已经创建;如需撤销仅处理本次新建ID3并核对其后业务引用,不能整库覆盖丢失后续修改。后台登录页面截图同目录 `hn-admin-login.png`。各实例账号独立存在,此本地结果不代表HN生产管理员已经建立。
+
+## EZ 购买套餐页精简(2026-10-27)
+
+用户指定:中文“商店”改为“购买套餐”,移除进入页面自动弹出的用户须知,以及全球节点、极速体验、流媒体解锁、多设备支持四张宣传卡片。HN 适配层在 `runtime-config.mjs` 设置 `SHOP_CONFIG.popup.enabled=false`、`showPlanFeatureCards=false`;Vite 适配仅改写简体中文模块的“商店”文案,覆盖导航、页面标题与返回入口。上游锁定子模块保持原样,其他实例和语言不改;套餐/价格/库存/支付/下单二次确认逻辑未改。
+
+本地浏览器已验证:导航与标题显示购买套餐,从仪表盘进入不再弹须知,四张宣传卡片不显示,套餐和购买按钮正常显示;一次性筛选空态及重置筛选可用,控制台无警告/错误。EZ同源配置测试及差异检查通过。未创建订单、未付款、未构建发布或修改dpl。截图和两文件修改前备份位于主工作区 `codex/output/hn-shop-cleanup-20261027/`。
+
+## 独立本地后台域名(2026-10-27)
+
+用户指定 `admin.hn.loc`。hosts 新增IPv4/IPv6回环映射;`/usr/local/etc/nginx/servers/admin.hn.loc.conf` 只监听127.0.0.1/[::1]:80,精确根 `/` 内部代理到既有PHP8592的 `/hn-preview-admin`,其他管理静态资源和API同源代理至同一PHP。浏览器地址保持 `http://admin.hn.loc/#/sign-in`,无需携带内部管理路径。未改app_url、secure_path、账户、认证中间件或前端构建产物;后台与hn.loc属于两个浏览器origin,登录状态分别保存,使用同一HN本地管理员。
+
+验收:解析仅双回环;管理HTML与8项静态资源均200;浏览器实际显示品牌登录界面且无警告/错误;在新域名实际管理员登录/受限接口200、访客403,准确比对登录前会话ID后仅撤销本次新增验收会话。hn.loc/dpl.loc首页仍200,原hn.loc虚拟主机摘要未变。未访问线上、未改其他站点或放宽监听/认证。
+
+安装候选、原hn.loc摘要及截图:主工作区 `codex/output/hn-admin-domain-20261027/`;管理员hosts备份/回执在 `/private/tmp/hn-admin-loc-20261027/`。回退只移除 `admin.hn.loc.conf` 与hosts中本次精确域名两行,保留hn.loc映射及并行配置;nginx配置检查通过后对核实的master平滑HUP。不覆盖整份hosts。
+
+## 公告与购买须知的区别(2026-10-27代码核对)
+
+购买套餐页须知仅由 `SHOP_CONFIG.popup.enabled` 控制,本次设false不涉及公告。网站公告由 `/api/v1/user/notice/fetch` 提供启用项,在仪表盘公告卡片展示标题/日期,查看详情打开公告弹窗;当前EZ自动弹窗只识别tags数组里的精确“弹窗”标签,选择返回列表中第一条符合者,并按公告ID在sessionStorage记录,当前标签页会话仅自动展示一次。后台独立popup字段不是该主题现有判断条件。未修改这套逻辑、未新建公告或做新的公告发布实测。
diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md
index dcfbf48..8e68259 100644
--- a/docs/maintenance/STATUS.md
+++ b/docs/maintenance/STATUS.md
@@ -2,6 +2,44 @@
文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。
+## admin.hn.loc 本地后台入口(2026-10-27,已启用)
+
+按用户要求新增独立后台域名admin.hn.loc,根路径内部映射现有管理应用,资源与API同源,双回环监听。浏览器登录页显示正常、无警告错误;管理HTML/8项资源200,真实管理员接口登录及权限200、访客403,仅撤销本次新增验收会话。hn.loc/dpl.loc回归200,原hn.loc配置未变;未改账号、认证或线上。备份与回退见 [SITE_PROFILES](SITE_PROFILES.md)。公告与商城须知为独立逻辑,代码核对结果也归入该正文,未发布测试公告。
+
+## HN 购买套餐页精简(2026-10-27,已本地验证)
+
+中文商店入口/标题已改为购买套餐;进入须知弹窗及四张宣传卡片通过HN适配配置关闭。上游子模块与dpl未改。浏览器验证从仪表盘进入、套餐/购买按钮、一次性筛选与重置均正常,未见弹窗/四卡或控制台警告错误;EZ配置测试通过。没有创建订单或付款,未构建、提交、推送或部署。实现与备份入口见 [SITE_PROFILES](SITE_PROFILES.md)。
+
+## hn 本地默认管理员(2026-10-27,已创建并验证)
+
+按用户要求核对ACC-005字段与目标独立SQLite:默认账号原先不存在,在线备份后经项目官方注册方法创建为用户ID3、有效管理员,未覆盖原用户密码。正常HTTP登录200且is_admin=true;受限管理接口200、访客403;唯一验收会话已撤销。浏览器确认本地后台登录界面正常,未在浏览器登录/保存密码。管理员入口、凭据引用和备份在 [SITE_PROFILES](SITE_PROFILES.md) 维护。只操作hn本地实例,未改dpl、认证策略或线上数据。
+
+## hn.loc 本地入口(2026-10-27,已启用)
+
+按用户要求新增固定域名 `http://hn.loc/`,仅双回环hosts与Nginx入口,普通请求到独立PHP8592,EZ同源开发资源与HMR到5184,Redis6387/SQLite演示库保持独立。开发服务已运行,未新增开机登录项。PHP直接使用/usr/local/bin/php,避免artisan误选不可执行的Herd链接。root安装脚本在文稿路径被macOS拒绝时未改权限,改从私有临时目录通过正常管理员授权安装;未放宽TCC。
+
+浏览器实际显示 hardnerd EZ 深色首页,Vite WebSocket connected,无警告/错误;解析仅127.0.0.1与::1,HTML、开发client/main.js、Logo、公开配置接口均200;既有dpl.loc/home.loc均200。14项站点回归/31断言与1项EZ配置测试通过。未登录或写入业务、未重建、未提交推送/部署。备份、启动和回退见 [SITE_PROFILES](SITE_PROFILES.md)。tpl-a未修改。
+
+## hn 当前使用 EZ(2026-10-27)
+
+用户决定 hn 先使用 EZ。独立工作树的 `config/site.php` 默认值、`SiteProfile` 和资源发布脚本回退值、`.env.hn.example` 均已改为 EZ;本地 `.env` 已是 `SITE_PROFILE=hn` 且未覆盖 `SITE_THEME`,因此沿用 EZ。未改私密环境、数据库或缓存;Hardnerd 功能草稿保留为显式选项,tpl-a 定稿未修改、未绑定 hn。
+
+本次站点配置/权限/命名空间回归 14 测试、31 断言通过;EZ 同源接口配置测试 1 项通过,PHP 语法与 `git diff --check` 通过。本次没有修改界面或重新构建资源,没有启动/切换预览服务,也未做新的浏览器验收。未合入 dpl 主项目、未提交本次改动、未推送或部署;线上仍未登记可执行的 hn 部署目标。历史设计预览与旧功能验收见下文,不代表本次上线。
+
+## tpl-a 设计定稿保留(2026-10-14,未绑定业务实例)
+
+用户确认当前效果可作为定稿方案保留,命名 tpl-a,不限定用于 hn;仅保存纯前端设计,吉祥物暂缓。最新要求明确“机房网络运营商”定位、解除此前扁平 SVG 限制并重新设计。当前 `frontends/tpl-a/` 首页改为写实机柜通道主视觉;接入方案、控制台及其他服务门户页面已统一重做,旧卡通机架 / 格纹 / 厚框眼镜 / 终端装饰已退出当前视觉。默认暗色、可切浅色,减少无关小字的要求保留。端口 8591 继续为独立 Vite 静态前端。
+
+当前版本已验证 1280px 明暗首页、接入方案与控制台;390px 七类门户页面无横向溢出,320px 首页、菜单和控制台无溢出。月 / 年付展示、方案选择、页面跳转、主题切换与模拟保存可用,浏览器没有运行错误。观察到的 11 个请求均留在本机 Vite,没有业务 API;源码无后端代理。构建与格式检查通过。机房影像为内置 imagegen 生成示意,价格、地区、线路、账户、流量和记录为演示,不据此声称实际机房资产或资源已验收。tpl-a 源码与素材已独立提交为 8b78424;未推送、部署,没有修改实际 dpl 环境或业务数据库。启动、源码、证据与限制见 [THEME_DESIGNS](THEME_DESIGNS.md)。
+
+## Hardnerd 功能草稿(2026-10-14,暂缓接入与验收)
+
+用户要求重新设计与 EZ / Stellar 均不同的 hn 专属主题。当前工作树切到 `codex/hn-custom-theme`,自有源码在 `frontends/hardnerd/`,主题包装 `theme/Hardnerd/`,独立热更新端口 5175。该历史草稿当时默认 Hardnerd;2026-10-27 已按用户要求改为默认 EZ,Hardnerd 保留为显式选项;dpl 选择不受该参数影响。当前改动尚未提交、推送或部署,不能视作正式发布。
+
+已实际显示首页和用户中心,虚构本地用户登录、套餐列表/确认、创建一笔本地待支付测试订单(未付款、未开通)已验证。新增主题/隔离回归 14 测试 / 31 断言、前台金额/流量/接口凭据 7 测试通过,构建通过。其余页面交互、手机断点、正式资源验收和最终文档仍需继续。原生取消确认在浏览器验证时阻塞,已改为主题内 dialog,尚待验证。没有修改实际 dpl 环境和业务数据库。
+
+以上记录属于此前功能草稿,不代表当前静态设计稿已经接入业务。用户已要求吉祥物先搁置,概念图没有批准或接入。后续须先确定视觉,再继续功能主题验收。
+
## hn / EZ 本地实现(2026-10-13)
独立工作树 `/Users/hellcat/Documents/Codex/2026-10-12/ji-cha/work/dpl-hn`,分支 `codex/hn-site-profile`,基于后端 `99f9b9ec927d924ce48f4bf6835a7edf604dcf46`。共享代码由 SITE_PROFILE 区分 dpl / hn,hn 使用锁定 EZ 源码和自有品牌,部署边界见 [SITE_PROFILES](SITE_PROFILES.md)。运行中的 dpl 后端、Stellar、实际 .env、数据库、Redis、队列、代理和生产服务器均未修改;主工作区既有修改保留。
diff --git a/docs/maintenance/THEME_DESIGNS.md b/docs/maintenance/THEME_DESIGNS.md
index d290145..d8d015a 100644
--- a/docs/maintenance/THEME_DESIGNS.md
+++ b/docs/maintenance/THEME_DESIGNS.md
@@ -2,7 +2,7 @@
## 当前范围与定稿
-2026-10-14,用户确认当前效果可作为定稿方案保留,命名为 `tpl-a`,不限定用于 hn。当前独立源码在 `frontends/tpl-a/`,设计定稿版本 1.0.0;业务接入仍未完成。hn 最终主题尚未选定,吉祥物暂缓。此前接入业务接口的 `frontends/hardnerd/` 功能草稿,以及 EZ / Stellar 独立保留。
+2026-10-14,用户确认当前效果可作为定稿方案保留,命名为 `tpl-a`,不限定用于 hn。当前独立源码在 `frontends/tpl-a/`,设计定稿版本 1.0.0;业务接入仍未完成。2026-10-27 用户确定 hn 先使用 EZ;tpl-a 仍不绑定 hn,吉祥物暂缓。此前接入业务接口的 `frontends/hardnerd/` 功能草稿,以及 EZ / Stellar 独立保留。
用户最新明确定位为“机房网络运营商”,解除此前 2D 扁平 / SVG 风格限制并要求重新设计。当前首页采用写实机柜通道主视觉、网络接入 / 套餐 / 技术服务入口与运营管理内容;接入方案、控制台、资源、账单、文档、工单和账户页面均重做为统一服务门户。使用深蓝、暖橙、自然空间影像与常规企业排版,不继续沿用卡通机架、格子衫 / 厚眼镜和终端装饰作为主视觉。保留减少无关小字的要求,只在全站一处注明影像和数据为示意。明暗双色保留,初次加载默认暗色。
@@ -40,6 +40,6 @@ npm --prefix frontends/tpl-a run dev
- 浏览器观察到的首页资源、页面跳转与模拟保存请求全部留在本机 Vite;未出现业务 API 请求。源码无 fetch / Axios / XHR 和后端 proxy。
- 构建与差异格式检查通过,浏览器没有运行错误。当前截图为 `codex/output/hn-dc-*.jpg`;`hn-hard-*` / `hn-operator-*` 为此前版本对照,均不作为定稿素材。
- 项目维护记录和 CoDocs hn/dpl 入口已同步。CoDocs 全库结构检查仍报告 13 项既有 Obsidian 主题目录/文件权限问题;本任务未改动这些无关配置,不能称全库检查通过。
-- 设计已由用户批准保存为 tpl-a;未推送、部署,hn 使用方案未定。业务接入、支付、邮件、订阅、节点连通与生产验收没有在本阶段执行。
+- 设计已由用户批准保存为 tpl-a;未推送、部署,hn 已选 EZ,tpl-a 使用实例仍未定。业务接入、支付、邮件、订阅、节点连通与生产验收没有在本阶段执行。
后续明确使用实例后,再将 tpl-a 接入功能主题,分别进行业务接入和部署验收。实例隔离和历史功能验证见 [SITE_PROFILES](SITE_PROFILES.md) 与 [STATUS](STATUS.md)。
diff --git a/frontends/hardnerd/index.html b/frontends/hardnerd/index.html
new file mode 100644
index 0000000..06788ec
--- /dev/null
+++ b/frontends/hardnerd/index.html
@@ -0,0 +1 @@
+
hardnerd
diff --git a/frontends/hardnerd/src/App.vue b/frontends/hardnerd/src/App.vue
new file mode 100644
index 0000000..d69f3cf
--- /dev/null
+++ b/frontends/hardnerd/src/App.vue
@@ -0,0 +1,17 @@
+
+
+跳到主要内容
+
+
+
+
WORKSPACE / {{state.user?.email?.split('@')[0]||'hardnerd'}}需要帮助?
+
+{{state.toast}}
+
diff --git a/frontends/hardnerd/src/Captcha.vue b/frontends/hardnerd/src/Captcha.vue
new file mode 100644
index 0000000..d1f9837
--- /dev/null
+++ b/frontends/hardnerd/src/Captcha.vue
@@ -0,0 +1,9 @@
+
diff --git a/frontends/hardnerd/src/Confirm.vue b/frontends/hardnerd/src/Confirm.vue
new file mode 100644
index 0000000..b18f236
--- /dev/null
+++ b/frontends/hardnerd/src/Confirm.vue
@@ -0,0 +1,6 @@
+
diff --git a/frontends/hardnerd/src/Icon.vue b/frontends/hardnerd/src/Icon.vue
new file mode 100644
index 0000000..c7dd833
--- /dev/null
+++ b/frontends/hardnerd/src/Icon.vue
@@ -0,0 +1,5 @@
+
+
diff --git a/frontends/hardnerd/src/core.mjs b/frontends/hardnerd/src/core.mjs
new file mode 100644
index 0000000..4615314
--- /dev/null
+++ b/frontends/hardnerd/src/core.mjs
@@ -0,0 +1,8 @@
+export const periods={month_price:'月付',quarter_price:'季付',half_year_price:'半年付',year_price:'年付',two_year_price:'两年付',three_year_price:'三年付',onetime_price:'一次性',reset_price:'重置流量'};
+export function money(cents=0){return (Number(cents||0)/100).toFixed(2);}
+export function bytes(value=0){const size=Number(value||0);if(size===0)return '0 B';const index=Math.min(4,Math.floor(Math.log(Math.max(1,size))/Math.log(1024)));return `${(size/1024**index).toFixed(index?1:0)} ${['B','KB','GB','TB','PB'][index]}`;}
+export function date(value){return value?new Date(Number(value)*1000).toLocaleDateString('zh-CN'):'—';}
+export function planPeriods(plan){return Object.keys(periods).filter(key=>key!=='reset_price'&&plan[key]!=null&&Number.isFinite(Number(plan[key])));}
+export function usage(subscription){const total=Number(subscription?.transfer_enable||0),used=Number(subscription?.u||0)+Number(subscription?.d||0);return {total,used,remaining:Math.max(0,total-used),percent:total>0?Math.min(100,Math.max(0,used/total*100)):0};}
+export function safeLink(value,origin){try{const url=new URL(value,origin);return ['http:','https:'].includes(url.protocol)?url.href:null;}catch{return null;}}
+export function statusLabel(status){return ['待支付','处理中','已取消','已完成','已折抵'][Number(status)]||'未知状态';}
diff --git a/frontends/hardnerd/src/main.js b/frontends/hardnerd/src/main.js
new file mode 100644
index 0000000..bee4ae9
--- /dev/null
+++ b/frontends/hardnerd/src/main.js
@@ -0,0 +1,18 @@
+import {createApp} from 'vue';
+import {createRouter,createWebHashHistory} from 'vue-router';
+import App from './App.vue';
+import Home from './pages/Home.vue';
+import Auth from './pages/Auth.vue';
+import Dashboard from './pages/Dashboard.vue';
+import Plans from './pages/Plans.vue';
+import Orders from './pages/Orders.vue';
+import Account from './pages/Account.vue';
+import Resources from './pages/Resources.vue';
+import {state,signOut,notify,api} from './state.mjs';
+import './style.css';
+const routes=[{path:'/',component:Home},{path:'/landing',redirect:'/'},...['login','register','forgot-password'].map(path=>({path:'/'+path,component:Auth})),{path:'/dashboard',component:Dashboard,meta:{auth:true}},{path:'/shop',component:Plans,meta:{auth:true}},{path:'/order-confirm',component:Plans,meta:{auth:true}},{path:'/payment',component:Orders,meta:{auth:true}},{path:'/orders',component:Orders,meta:{auth:true}},{path:'/profile',component:Account,meta:{auth:true}},...['nodes','tickets','docs','invite'].map(path=>({path:'/'+path,component:Resources,meta:{auth:true}})),{path:'/:pathMatch(.*)*',redirect:'/'}];
+const router=createRouter({history:createWebHashHistory(),routes,scrollBehavior:()=>({top:0})});
+router.beforeEach(to=>{if(to.meta.auth&&!state.token)return {path:'/login',query:{redirect:to.fullPath}};document.title=(to.path==='/'?'连接不止于此':({'order-confirm':'确认订单',payment:'订单支付','forgot-password':'找回密码',dashboard:'概览',shop:'套餐',orders:'订单',profile:'账户',tickets:'支持',docs:'指南',nodes:'节点',invite:'邀请',login:'登录',register:'注册'}[to.path.slice(1)]||'hardnerd'))+' · '+(window.settings?.title||'hardnerd');});
+window.addEventListener('hardnerd-expired',()=>{notify('登录已过期,请重新登录');router.replace('/login');});
+api('/guest/comm/config').then(config=>(state.config=config||{},state.configReady=true)).catch(error=>{state.configError=true;notify(error.message);});
+createApp(App).use(router).mount('#app');
diff --git a/frontends/hardnerd/src/pages/Account.vue b/frontends/hardnerd/src/pages/Account.vue
new file mode 100644
index 0000000..12c8c98
--- /dev/null
+++ b/frontends/hardnerd/src/pages/Account.vue
@@ -0,0 +1,8 @@
+04 / MAKE YOURSELF AT HOME你的空间,你作主.
账户信息、提醒偏好与登录设备。
HN / ACCOUNT{{error}}
{{(state.user?.email||'H').slice(0,1).toUpperCase()}}{{state.user?.email?.split('@')[0]||'探索者'}}
{{state.user?.email||'正在读取…'}}
HARDNERD MEMBER加入时间{{date(state.user?.created_at)}}
账户余额¥{{money(state.user?.balance)}}
上次登录{{date(state.user?.last_login_at)}}
邀请好友登录设备
SESSIONS暂无设备记录。
{{session.device_info?.device||session.device_info?.browser||'登录设备'}}{{date(session.last_used_at?Date.parse(session.last_used_at)/1000:0)}}
diff --git a/frontends/hardnerd/src/pages/Auth.vue b/frontends/hardnerd/src/pages/Auth.vue
new file mode 100644
index 0000000..9904221
--- /dev/null
+++ b/frontends/hardnerd/src/pages/Auth.vue
@@ -0,0 +1,15 @@
+
+← 回到首页
diff --git a/frontends/hardnerd/src/pages/Dashboard.vue b/frontends/hardnerd/src/pages/Dashboard.vue
new file mode 100644
index 0000000..1d4439f
--- /dev/null
+++ b/frontends/hardnerd/src/pages/Dashboard.vue
@@ -0,0 +1,5 @@
+01 / YOUR CONNECTION AT A GLANCE{{greeting}},探索者.
你的连接近况,都在这里。
正在读取你的空间…
暂时没有读到账户数据
MY CONNECTION / 我的连接{{state.subscription?.plan_id?'已有套餐':'尚未订阅'}}
{{state.subscription?.plan?.name||'为下一次探索,\n选一条新连接。'}}
{{state.subscription?.plan_id?'每一次好奇,都值得一个顺畅的开始。':'选择适合的套餐,把世界再打开一点。'}}
{{state.subscription?.plan_id?'开始连接':'查看套餐'}}HN / YOUR NEXT CHAPTER
↗
本期流量
DATA / 01{{metric.percent.toFixed(0)}}%已使用
剩余流量{{bytes(metric.remaining)}}{{bytes(metric.used)}} / {{bytes(metric.total)}}
账户余额¥{{money(state.user.balance)}}用于账户内消费
当前套餐{{state.subscription?.plan?.name||'暂未订阅'}}{{state.subscription?.plan_id?'查看连接与节点':'选择一个适合你的套餐'}}
下次流量重置{{date(state.subscription?.next_reset_at)}}以当前套餐规则为准
来自 hardnerd
UPDATES公告暂未加载,稍后重试。
ALL QUIET HERE.暂无新公告。继续你的探索吧。
{{notice.title}}{{date(notice.updated_at)}}
diff --git a/frontends/hardnerd/src/pages/Home.vue b/frontends/hardnerd/src/pages/Home.vue
new file mode 100644
index 0000000..7f8c86b
--- /dev/null
+++ b/frontends/hardnerd/src/pages/Home.vue
@@ -0,0 +1,11 @@
+
+
{{brand}}NET{{state.token?'进入控制台':'登录账户'}}
+
FOR THE CURIOUS. / 为好奇而连接连接不止
于此.
你的世界,不必只有眼前这一面。
把连接交给 hardnerd,把好奇留给自己。
{{state.token?'打开我的空间':'开启你的连接'}}已有账户?登录 ↗
+THE WORLD IS CLOSER
THAN YOU THINK.
OPEN A NEW PERSPECTIVE ↗
+STAY CURIOUS.✳CONNECT DIFFERENT.✳HARDNERD.
+02 / LESS FRICTION. MORE WORLD.
让连接简单。
把空间留给探索。
01看得清楚,用得明白
套餐、用量与到期时间,一眼就能找到。每一次选择,都有清楚的信息。
02从这里,开始连接
订阅入口、节点列表与使用指南,集中在你的空间里。少一点寻找,多一点行动。
03遇到问题,有处可问
把问题交给工单,让沟通有记录,让进展可追踪。
+YOUR NEXT CONNECTION STARTS HERE.下一站,
由你定义.
{{state.token?'选择适合的套餐':'加入 hardnerd'}}↗
diff --git a/frontends/hardnerd/src/pages/Orders.vue b/frontends/hardnerd/src/pages/Orders.vue
new file mode 100644
index 0000000..0d0c842
--- /dev/null
+++ b/frontends/hardnerd/src/pages/Orders.vue
@@ -0,0 +1,9 @@
+03 / EVERY STEP, IN ONE PLACE{{paying?'完成这次连接':'你的每一次选择'}}.
{{paying?'订单已创建。选择支付方式,完成开通。':'订单记录与处理进度,清楚可查。'}}
← 返回订单正在读取订单…
{{statusLabel(detail.status)}}{{detail.plan?.name||'套餐订单'}}
{{detail.trade_no}}
计费周期{{periods[detail.period]||detail.period}}
创建时间{{date(detail.created_at)}}
订单金额¥{{money(detail.total_amount)}}
手续费¥{{money(detail.handling_amount)}}
应付金额¥{{money(Number(detail.total_amount)+Number(detail.handling_amount||0))}}
A FRESH START.第一笔连接,还在前方。
你还没有订单。找到适合的套餐,开始新的探索。
浏览套餐 订单 / 套餐金额日期状态
{{order.plan?.name||'套餐订单'}}{{order.trade_no}}
¥{{money(order.total_amount)}}{{date(order.created_at)}}{{statusLabel(order.status)}}
diff --git a/frontends/hardnerd/src/pages/Plans.vue b/frontends/hardnerd/src/pages/Plans.vue
new file mode 100644
index 0000000..1dd42b6
--- /dev/null
+++ b/frontends/hardnerd/src/pages/Plans.vue
@@ -0,0 +1,10 @@
+02 / FIND YOUR CONNECTION{{confirming?'确认你的选择':'给好奇,留点余量'}}.
{{confirming?'核对套餐与周期,再创建订单。':'根据实际需求,找到适合的连接方式。'}}
← 返回套餐列表HN / PLANS正在读取套餐…
YOUR SELECTION{{selected.name}}
选择计费周期
流量额度 {{selected.transfer_enable}} GB速率上限 {{selected.speed_limit==null?'不限速':selected.speed_limit+' Mbps'}}
没有找到这个套餐
返回套餐列表套餐正在准备中
目前暂无可购买套餐,请稍后回来看看。
PLAN / {{String(index+1).padStart(2,'0')}}
{{plan.name}}
¥{{money(plan[selections[plan.id]])}}/ {{periods[selections[plan.id]]}}
{{plan.transfer_enable}} GB 流量{{plan.speed_limit==null?'不限速':plan.speed_limit+' Mbps 速率上限'}}{{(plan.display_device_limit??plan.device_limit)?(plan.display_device_limit??plan.device_limit)+' 台设备':'设备数量不限'}}
不确定怎么选?告诉我们你的使用需求。联系支持 ↗
diff --git a/frontends/hardnerd/src/pages/Resources.vue b/frontends/hardnerd/src/pages/Resources.vue
new file mode 100644
index 0000000..204ed9b
--- /dev/null
+++ b/frontends/hardnerd/src/pages/Resources.vue
@@ -0,0 +1,23 @@
+{{heading[0]}}{{heading[1]}}
{{heading[2]}}
← 返回列表HN / {{kind.toUpperCase()}}
+YOUR SUBSCRIPTION一个地址,把连接带走。
复制订阅地址,在客户端中导入。订阅地址仅供你本人使用。
查看使用指南
+
+正在读取{{kind==='tickets'?'工单':kind==='nodes'?'节点':kind==='docs'?'指南':'邀请记录'}}…
+可用节点 {{Array.isArray(data)?data.length:0}}
{{state.subscription?.plan_id?'暂无可用节点':'你的第一条连接,还没开始。'}}
{{state.subscription?.plan_id?'请稍后重试,或通过工单联系支持。':'购买套餐后,可在这里查看对应节点。'}}
查看套餐{{node.name}}
{{node.is_online?'在线':'离线'}}{{node.type}}{{node.rate}}× 倍率
+{{data.subject}}
{{data.status?'已关闭':'处理中'}}{{item.is_me?'我':'hardnerd 支持'}} · {{date(item.created_at)}}{{item.message}}
这里很安静。
你还没有工单。有问题时,我们就在这里。
+{{data.category}}{{data.title}}
探索指南
指南正在准备中。
如果你需要帮助,可以先创建工单。
联系支持{{doc.category}}{{doc.title}}
阅读指南
+SHARE A GOOD CONNECTION.把好奇,
带给朋友。
创建你的专属邀请链接,分享给需要的人。
↗
↗已邀请用户{{data?.stat?.[0]||0}}注册人数
累计佣金¥{{money(data?.stat?.[1])}}以实际邀请记录为准
可用佣金¥{{money(data?.stat?.[4])}}奖励比例 {{data?.stat?.[3]||0}}%
你的邀请链接
PASS IT ON.还没有邀请码,点击上方按钮创建。
{{item.code}}
diff --git a/frontends/hardnerd/src/state.mjs b/frontends/hardnerd/src/state.mjs
new file mode 100644
index 0000000..01eafda
--- /dev/null
+++ b/frontends/hardnerd/src/state.mjs
@@ -0,0 +1,24 @@
+import {reactive} from 'vue';
+const key='hardnerd.auth';
+function stored(){try{return sessionStorage.getItem(key)||localStorage.getItem(key)||'';}catch{return '';}}
+export const state=reactive({token:stored(),user:null,subscription:null,config:{},configReady:false,configError:false,confirm:null,toast:'',busy:false,ready:false});
+let toastTimer;
+export function notify(message){state.toast=message;clearTimeout(toastTimer);toastTimer=setTimeout(()=>state.toast='',5000);}
+export function signIn(token,remember=false){if(typeof token!=='string'||!token.startsWith('Bearer '))throw new Error('登录凭据格式不正确');localStorage.removeItem(key);sessionStorage.removeItem(key);(remember?localStorage:sessionStorage).setItem(key,token);state.token=token;state.user=null;state.subscription=null;state.ready=false;}
+export function signOut(){localStorage.removeItem(key);sessionStorage.removeItem(key);state.token='';state.user=null;state.subscription=null;state.ready=false;}
+export async function api(path,{method='GET',data,raw=false}={}){
+ if(!path.startsWith('/')||path.startsWith('//'))throw new Error('接口地址无效');
+ const sentToken=state.token;const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),20000);
+ try{
+ const response=await fetch('/api/v1'+path,{method,credentials:'same-origin',redirect:'error',signal:controller.signal,headers:{Accept:'application/json',...(sentToken?{Authorization:sentToken}:{}),...(data?{'Content-Type':'application/json'}:{})},...(data?{body:JSON.stringify(data)}:{})});
+ let body;try{body=await response.json();}catch{throw new Error('服务暂时不可用,请稍后重试');}
+ if(state.token===sentToken&&(response.status===401||(response.status===403&&body.message==='Unauthorized'))){signOut();window.dispatchEvent(new Event('hardnerd-expired'));}
+ if(!response.ok){const detail=body.errors?Object.values(body.errors).flat()[0]:null;throw new Error(detail||body.message||'操作失败,请稍后重试');}
+ return raw?body:body.data;
+ }catch(error){if(error.name==='AbortError')throw new Error('请求超时,请检查网络后重试');throw error;}finally{clearTimeout(timer);}
+}
+export async function loadAccount(){const token=state.token;state.ready=false;try{const [user,subscription]=await Promise.all([api('/user/info'),api('/user/getSubscribe')]);if(state.token===token){state.user=user;state.subscription=subscription;}}finally{if(state.token===token)state.ready=true;}}
+export async function action(callback,success){if(state.busy)return;state.busy=true;try{await callback();if(success)notify(success);}catch(error){notify(error.message);}finally{state.busy=false;}}
+export async function copy(text){if(!text){notify('暂无可复制的内容');return;}try{await navigator.clipboard.writeText(text);notify('已复制');}catch{notify('浏览器未允许复制,请手动选择内容');}}
+
+export function askConfirmation(message){if(state.confirm)state.confirm.resolve(false);return new Promise(resolve=>state.confirm={message,resolve});}
diff --git a/frontends/hardnerd/src/style.css b/frontends/hardnerd/src/style.css
new file mode 100644
index 0000000..c42c7bb
--- /dev/null
+++ b/frontends/hardnerd/src/style.css
@@ -0,0 +1,18 @@
+:root{font-family:Arial,'PingFang SC','Microsoft YaHei',sans-serif;color:#171b1a;background:#f4f3ee;font-synthesis:none;--paper:#f4f3ee;--ink:#171b1a;--lime:#b4f047;--line:#dadcd3;--muted:#6f756d;--white:#fff;--mono:'SFMono-Regular',Consolas,monospace}*{box-sizing:border-box}body{margin:0}a{color:inherit;text-decoration:none}button,input,select,textarea{font:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:wait;opacity:.55}button,select,input,textarea{color:inherit}button{background:none;border:0}button svg,a svg{flex-shrink:0}svg{width:22px;height:22px}h1,h2,h3,p{margin:0}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:3px solid #79a322;outline-offset:4px}main:focus{outline:0}.skip-link{position:fixed;top:-80px;left:16px;background:var(--lime);padding:12px;z-index:100}.skip-link:focus{top:12px}.brand{display:flex;align-items:center;gap:10px;font-size:25px;font-weight:900;letter-spacing:-1.2px}.brand img{width:36px;height:36px}.brand sup,.word-dot{font-size:10px;align-self:flex-start;margin-top:4px}.mono,.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:1.2px}.eyebrow{display:flex;align-items:center;gap:9px;line-height:1.6;color:var(--muted);font-weight:600}.tiny-dot{width:7px;height:7px;display:inline-block;background:var(--lime);border-radius:50%;flex-shrink:0}.muted,.hint{color:var(--muted);font-size:14px;line-height:1.7}.hint{font-size:12px}.button{display:inline-flex;justify-content:center;align-items:center;gap:16px;min-height:48px;padding:13px 22px;font-weight:700;font-size:14px;border-radius:3px;transition:background .15s,transform .15s}.button:hover:not(:disabled){transform:translateY(-2px)}.button.primary{background:var(--lime);color:var(--ink);border:1px solid transparent}.button.primary:hover{background:#c4fa6a}.button.dark{background:var(--ink);color:var(--white)}.button.light{background:var(--paper);color:var(--ink)}.button.outline{border:1px solid var(--line)}.button.full{width:100%;margin-top:16px}.text-button{display:inline-flex;align-items:center;gap:10px;font-size:13px;font-weight:600;padding:9px 0}.icon-button{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border:1px solid var(--line);border-radius:3px}.outlined{-webkit-text-stroke:1.6px var(--ink);color:transparent}.lime-dot{color:#87b832}.landing{max-width:1600px;margin:auto}.landing-nav{display:flex;justify-content:space-between;align-items:center;height:106px;padding:0 6%;border-bottom:1px solid var(--line)}.landing-nav nav{display:flex;gap:34px;font-size:13px}.nav-enter{display:flex;align-items:center;gap:20px;font-size:13px;border-bottom:1px solid var(--ink);padding-bottom:8px}.hero{display:grid;grid-template-columns:1fr 1fr;min-height:650px;border-bottom:1px solid var(--line)}.hero-copy{padding:60px 0 0 12%;display:flex;flex-direction:column}.hero h1{font-size:clamp(60px,7.7vw,116px);font-weight:900;line-height:1.12;letter-spacing:-6px;margin:29px 0}.hero-copy>p{font-size:17px;color:#666d62;line-height:1.9}.hero-actions{display:flex;align-items:center;gap:24px;margin:32px 0 46px}.hero-foot{border-top:1px solid var(--line);padding:18px 18px 18px 0;display:flex;justify-content:space-between;margin-top:auto;color:#858b80;font-size:9px}.hero-art{position:relative;margin:32px 32px 28px 35px;background:#e9ecdf;border:1px solid #d9ddcf;overflow:hidden}.orbit-svg{width:100%;height:100%;min-height:560px}.art-caption{position:absolute;top:28px;left:30px;font-size:12px;line-height:1.8;z-index:1}.art-note{position:absolute;right:24px;bottom:28px;font-size:9px}.brand-band{display:flex;align-items:center;justify-content:space-around;padding:24px;background:var(--lime);font-size:22px;font-weight:900;letter-spacing:-.5px}.band-star{font-size:36px;font-weight:400}.manifesto{padding:100px 6%;display:grid;grid-template-columns:1fr 1fr;gap:70px}.manifesto h2{font-size:38px;line-height:1.4;margin-top:25px;letter-spacing:-1px}.principles article{display:flex;gap:30px;border-top:1px solid var(--line);padding:25px 0}.principles h3{font-size:18px;margin-bottom:12px}.principles p{font-size:14px;line-height:1.9;color:var(--muted)}.principles article>.mono{color:#8c9388;padding-top:4px}.start-panel{position:relative;background:var(--ink);color:var(--paper);margin:0 6% 50px;padding:58px;overflow:hidden}.start-panel .eyebrow{color:#aab29f}.start-panel h2{font-size:60px;font-weight:800;line-height:1.3;margin:24px 0 30px;letter-spacing:-2px}.start-panel h2 span{color:var(--lime)}.start-mark{position:absolute;right:40px;top:15px;font-size:310px;line-height:1.1;color:var(--lime)}.landing-footer{display:flex;justify-content:space-between;align-items:center;padding:35px 6%;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}.landing-footer .brand{color:var(--ink)}
+/* Independent hardnerd workspace */
+.workspace{min-height:100vh;display:flex}.sidebar{background:var(--ink);color:#d5d9cf;position:fixed;inset:0 auto 0 0;width:224px;display:flex;flex-direction:column;padding:36px 20px 20px;z-index:20}.side-brand{font-size:25px;color:var(--paper);gap:8px;margin:0 4px 54px}.side-brand img{width:32px;height:32px}.nav-label{font:9px var(--mono);letter-spacing:1.4px;color:#7b8578;margin:0 14px 18px}.sidebar nav{display:grid;gap:7px}.sidebar nav a{display:flex;align-items:center;gap:12px;padding:14px;border-radius:3px;font-size:13px}.sidebar nav a svg{width:19px;height:19px;color:#84907f}.sidebar nav a:hover{background:#242b22}.sidebar nav a.router-link-active{color:var(--lime);background:#2a3421}.sidebar nav a.router-link-active svg{color:var(--lime)}.nav-pointer{margin-left:auto;opacity:0}.router-link-active .nav-pointer{opacity:1}.side-bottom{margin-top:auto;padding-top:35px}.invite-link{display:flex;gap:9px;align-items:center;font-size:11px;background:#252d20;padding:14px 11px;color:#c7dca9;margin-bottom:22px}.invite-link svg{width:17px;height:17px}.invite-link span{margin-left:auto}.identity{display:flex;align-items:center;gap:9px;border-top:1px solid #333b2f;padding-top:22px}.avatar{display:flex;align-items:center;justify-content:center;background:#3d482e;color:var(--lime);font:bold 17px var(--mono);width:34px;height:34px;flex-shrink:0;border-radius:50%}.identity b{display:block;font-size:11px}.identity small{display:block;font-size:9px;color:#8f9988;margin-top:4px;max-width:118px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.identity svg{width:16px;height:16px;margin-left:auto;color:#7c8874}.logout{color:#97a18d;display:flex;gap:8px;align-items:center;font-size:11px;margin:20px 0 25px}.logout svg{width:14px;height:14px}.side-footer{font:8px var(--mono);letter-spacing:.5px;color:#66725d}.work-main{width:calc(100% - 224px);margin-left:224px;min-height:100vh;display:flex;flex-direction:column}.work-top{height:76px;border-bottom:1px solid var(--line);padding:0 4.5%;display:flex;align-items:center;justify-content:space-between}.work-top>.mono{font-size:9px;color:var(--muted)}.slash{color:#a4ab9b;padding:0 10px}.top-help{display:flex;align-items:center;gap:8px;font-size:11px}.top-help svg{width:14px;height:14px}.work-main main{padding:40px 4.5% 55px;max-width:1500px;width:100%;margin:0 auto;flex:1}.page-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:32px}.page-heading h1{font-size:36px;letter-spacing:-1.5px;line-height:1.3;margin:12px 0}.page-heading p{font-size:13px;color:var(--muted);line-height:1.8}.number-tag{font:9px var(--mono);color:var(--muted);padding:13px;border:1px solid var(--line);white-space:nowrap}.panel{background:#fcfcf8;border:1px solid var(--line);border-radius:4px;padding:26px}.panel-title{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:24px}.panel-title h2{font-size:16px;font-weight:700}.panel-title .mono{font-size:8px;color:#929988}.dashboard-grid{display:grid;grid-template-columns:1.35fr 1fr;gap:22px}.connection-card{background:var(--ink);color:var(--paper);position:relative;overflow:hidden;padding:28px;border-radius:4px;min-height:265px}.connection-top{display:flex;gap:14px;align-items:center;justify-content:space-between;position:relative;z-index:1}.connection-top .eyebrow{color:#a5b195;font-size:9px}.connection-card h2{font-size:29px;letter-spacing:-.8px;line-height:1.45;white-space:pre-line;position:relative;z-index:1;margin:26px 0 10px;max-width:330px}.connection-card>p{font-size:11px;color:#a4ad9b;position:relative;z-index:1}.connection-bottom{margin-top:30px;display:flex;align-items:center;justify-content:space-between;gap:15px;position:relative;z-index:1}.connection-bottom .button{font-size:12px;min-height:40px;padding:11px 17px}.connection-bottom>.mono{font-size:7px;color:#8f9d81}.connection-orbit{position:absolute;border:1px solid #35402d;width:220px;height:220px;right:-76px;top:72px;border-radius:50%;display:flex;align-items:center;justify-content:center;transform:rotate(-12deg)}.connection-orbit:after{content:'';width:145px;height:145px;position:absolute;border:1px solid #37462a;border-radius:50%}.connection-orbit span{font-size:125px;line-height:1;color:#b4f04740}.status-pill{padding:5px 10px;font-size:10px;border:1px solid #d5d8ce;background:#f0f1e9;border-radius:20px;white-space:nowrap;display:inline-block;color:#727b68}.status-pill.active{background:#e7f1d7;border-color:#d5e6ba;color:#496722}.connection-card .status-pill{border-color:#424e34;background:#2d3922;color:#b4cd91}.usage-card{display:flex;flex-direction:column}.usage-body{display:flex;align-items:center;gap:22px;flex:1;padding:12px 0 20px}.usage-ring{border-radius:50%;background:conic-gradient(var(--lime) var(--usage),#e6e9df 0);width:118px;height:118px;padding:9px;flex-shrink:0}.usage-ring>div{background:#fcfcf8;border-radius:50%;width:100%;height:100%;display:flex;flex-direction:column;justify-content:center;align-items:center}.usage-ring strong{font-size:28px;letter-spacing:-1px}.usage-ring strong small{font-size:14px}.usage-ring span{font-size:9px;color:#929988;margin-top:4px}.usage-numbers{display:flex;flex-direction:column;gap:10px}.usage-numbers span{font-size:10px;color:var(--muted)}.usage-numbers strong{font-size:24px;letter-spacing:-.8px}.usage-footer{display:flex;justify-content:space-between;padding-top:17px;border-top:1px solid var(--line);font-size:11px}.usage-footer span{color:var(--muted)}.stat-strip{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--line);border-radius:4px;margin:22px 0;background:#fcfcf8}.stat-strip>div{padding:24px 27px;display:flex;flex-direction:column;gap:13px;border-right:1px solid var(--line)}.stat-strip>div:last-child{border:0}.stat-strip span{color:var(--muted);font-size:11px}.stat-strip strong{font-size:27px;letter-spacing:-.8px}.stat-strip strong.small-value{font-size:18px;padding-top:6px;min-height:31px}.stat-strip small{font-size:9px;color:#939a8b}.lower-grid{display:grid;grid-template-columns:1.1fr 1fr;gap:22px}.quick-row{display:flex;align-items:center;gap:14px;text-align:left;width:100%;padding:18px 0;border-top:1px solid var(--line)}.quick-row b{font-size:12px}.quick-row p{font-size:10px;color:var(--muted);margin-top:7px}.quick-row>svg{margin-left:auto;width:16px;height:16px;color:#86947b}.square-icon{width:38px;height:38px;background:#eef1e6;display:flex;align-items:center;justify-content:center;flex-shrink:0}.square-icon svg{width:18px;height:18px}.quiet-state{padding:24px 0}.quiet-state .mono{font-size:9px;color:#9aa28e}.quiet-state p{font-size:12px;color:var(--muted);margin-top:14px;line-height:1.8}.notices details{border-top:1px solid var(--line);padding:17px 0;font-size:12px}.notices summary{cursor:pointer}.notices summary span{font-size:9px;color:var(--muted);float:right}.work-footer{padding:25px 4.5%;border-top:1px solid var(--line);display:flex;justify-content:space-between;gap:16px;font-size:9px;color:#8e9685}.mobile-nav{display:none}.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:14px;background:var(--ink);color:var(--paper);border:1px solid #39492c;padding:17px 20px;z-index:80;max-width:90vw;font-size:13px;box-shadow:0 8px 28px #0002}.toast>svg{color:var(--lime);width:18px;height:18px}.toast button{display:flex;color:#a4b596;margin-left:16px}.toast button svg{width:15px;height:15px}.loading-state{padding:70px 0;text-align:center;font-size:14px;color:var(--muted)}.empty{text-align:center;padding:65px 28px}.empty>svg{width:36px;height:36px;color:#8da264;margin-bottom:20px}.empty>.eyebrow{justify-content:center;margin:10px 0}.empty h2{font-size:24px;margin-bottom:13px}.empty p{font-size:13px;color:var(--muted);line-height:1.8;margin-bottom:25px}.error-note{color:#a14b27;margin:0 0 20px;background:#fff0e5;padding:15px}.prose{font-size:13px;line-height:1.9;color:#67715d;overflow-wrap:anywhere}.prose>*+*{margin-top:12px}.prose ul{padding-left:20px}.prose h2,.prose h3{color:var(--ink);font-size:19px}.prose img{max-width:100%}.prose a{text-decoration:underline;color:#527a23}.prose pre{padding:16px;background:#edf0e5;overflow:auto}.prose table{width:100%;border-collapse:collapse}.prose td,.prose th{border:1px solid var(--line);padding:8px}label{display:block;font-size:12px;font-weight:600;margin:20px 0}input,select,textarea{width:100%;border:1px solid var(--line);background:#fff;padding:13px 14px;border-radius:3px;margin-top:9px;font-size:13px;min-width:0}input::placeholder,textarea::placeholder{color:#a0a695;font-weight:400}input[type=checkbox],input[type=radio]{width:16px;height:16px;margin:0;accent-color:#86b633;flex-shrink:0}textarea{resize:vertical;line-height:1.7}.checkbox{display:flex;gap:9px;align-items:center;font-weight:400;line-height:1.8}.input-action{display:flex;align-items:stretch;margin-top:9px}.input-action input{margin:0;border-radius:3px 0 0 3px}.input-action button{border:1px solid var(--line);border-left:0;white-space:nowrap;background:#edf2e5;padding:10px 13px;font-size:11px;border-radius:0 3px 3px 0}.field-pair{display:grid;grid-template-columns:1fr 1fr;gap:18px}.form-actions{display:flex;align-items:center;gap:25px}
+/* Auth */
+.auth-layout{display:grid;grid-template-columns:45% 55%;min-height:100vh}.auth-art{background:var(--ink);color:var(--paper);padding:45px 11%;display:flex;flex-direction:column;overflow:hidden;position:relative}.auth-art .brand{color:var(--paper);margin-bottom:85px}.auth-art .eyebrow{color:#9ba58f;font-size:9px}.auth-slogan{font-size:clamp(30px,3.7vw,58px);line-height:1.55;font-weight:800;letter-spacing:-1px;margin-top:24px;position:relative;z-index:1}.auth-slogan span{color:var(--lime)}.auth-art>.mono{margin-top:auto;padding-top:180px;color:#81926e;font-size:8px}.auth-diagram{position:absolute;bottom:95px;right:10px;width:270px;height:270px}.diagram-ring{position:absolute;inset:0;border:1px solid #404f32;border-radius:50%;transform:rotate(-30deg)}.diagram-ring:after{content:'';position:absolute;inset:50px;border:1px solid #3a482d;border-radius:50%}.auth-diagram>span{position:absolute;top:110px;left:95px;color:var(--lime);font:bold 34px var(--mono)}.auth-diagram i{position:absolute;top:30px;left:32px;width:18px;height:18px;background:var(--lime);border-radius:50%}.auth-diagram>b{position:absolute;right:17px;bottom:36px;font-size:85px;font-weight:400;color:#596b45}.auth-main{padding:40px 12%;display:flex;flex-direction:column;justify-content:space-between}.auth-back{font-size:12px;color:var(--muted)}.auth-form{width:100%;max-width:400px;margin:55px auto}.auth-form h1{font-size:31px;letter-spacing:-1px;line-height:1.4;margin:20px 0 12px}.auth-form>.muted{font-size:13px;margin-bottom:30px}.auth-form form{margin-top:25px}.auth-options{display:flex;justify-content:space-between;align-items:center;font-size:11px;margin:20px 0}.auth-options .checkbox{font-size:11px;margin:0}.auth-switch{margin-top:28px;padding-top:25px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}.auth-switch a{font-weight:600;color:var(--ink);margin-left:10px}.auth-main footer{font-size:8px;color:#9ea692}.captcha{margin:20px 0}.captcha p{color:#a14b27;font-size:12px;line-height:1.7}
+/* Plans, orders, and accounts */
+.plan-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:22px}.plan-card{background:#fcfcf8;border:1px solid var(--line);border-radius:4px;padding:27px;max-width:420px}.plan-card:hover{border-color:#89b744}.plan-top{display:flex;justify-content:space-between;align-items:center;color:#89967a;margin-bottom:32px}.plan-top .mono{font-size:9px}.plan-card h2{font-size:23px;line-height:1.4;margin-bottom:24px}.plan-price{display:flex;align-items:baseline;gap:5px}.plan-price>span{font-size:20px}.plan-price strong{font-size:48px;font-weight:800;letter-spacing:-2px}.plan-price small{font-size:10px;color:var(--muted);margin-left:5px}.period-select{font-size:10px;color:#818d75;margin:24px 0}.period-select select{font-size:12px;color:var(--ink);background:var(--paper)}.plan-facts{display:flex;flex-direction:column;gap:14px;border-top:1px solid var(--line);padding:23px 0;font-size:12px}.plan-facts>span{display:flex;align-items:center;gap:10px;justify-content:space-between}.plan-facts>span:has(svg){justify-content:flex-start}.plan-facts svg{width:16px;height:16px;color:#78a436}.plan-description{min-height:65px;margin-bottom:15px}.help-strip{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:32px;padding:22px 0;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}.help-strip>span{display:flex;align-items:center;gap:10px}.help-strip svg{width:16px;height:16px}.help-strip a{color:var(--ink);white-space:nowrap}.checkout-grid{display:grid;grid-template-columns:1.2fr 1fr;gap:25px}.checkout-plan h2,.checkout-summary h2{font-size:23px;margin:18px 0 25px;line-height:1.4}.checkout-plan h3{font-size:13px;margin:30px 0 18px}.period-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin-bottom:25px}.period-grid button{border:1px solid var(--line);padding:18px;display:flex;flex-direction:column;gap:14px;align-items:flex-start;border-radius:3px;background:var(--paper)}.period-grid button.selected{border-color:#7faa34;background:#eef5e0}.period-grid span{font-size:12px}.period-grid strong{font-size:20px}.summary-line{display:flex;justify-content:space-between;gap:25px;margin:22px 0;font-size:12px;line-height:1.7}.summary-line>span{color:var(--muted)}.summary-total{border-top:1px solid var(--line);padding-top:24px;margin-top:30px;display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:20px;font-size:13px}.summary-total strong{font-size:30px;letter-spacing:-1px}.order-number{font-size:10px;color:var(--muted);overflow-wrap:anywhere}.order-table{padding:0;overflow:auto}.table-row{display:grid;grid-template-columns:2.3fr 1fr 1fr 1fr 1.15fr;align-items:center;gap:14px;padding:22px;border-bottom:1px solid var(--line);font-size:12px;min-width:650px}.table-row:last-child{border:0}.table-head{background:#eef0e6;color:#858d7c;font-size:10px;padding:15px 22px}.table-row small{display:block;font-size:9px;color:var(--muted);margin-top:9px;overflow-wrap:anywhere}.table-row .status-pill{width:fit-content}.table-row .text-button{font-size:11px}.payment-option{display:flex;align-items:center;gap:12px;background:var(--paper);border:1px solid var(--line);padding:16px;margin:12px 0}.payment-option .hint{font-size:9px;margin-left:auto}.payment-qr{width:200px;max-width:100%;display:block;margin:20px auto}.account-grid{display:grid;grid-template-columns:.8fr 1.7fr;gap:24px;align-items:start}.account-profile{text-align:center}.avatar.large{width:75px;height:75px;font-size:30px;margin:12px auto 22px;background:#e5edda;color:#668632}.account-profile h2{font-size:25px;margin-bottom:10px;overflow-wrap:anywhere}.email{font-size:11px;overflow-wrap:anywhere}.account-tag{display:inline-block;border:1px solid #dde5d3;padding:8px 10px;font:8px var(--mono);color:#748862;margin:22px 0}.profile-detail{display:flex;justify-content:space-between;gap:15px;padding:18px 0;border-top:1px solid var(--line);font-size:11px}.profile-detail span{color:var(--muted)}.account-settings{display:grid;gap:23px}.account-settings .panel{padding:28px}.toggle-row{display:flex;align-items:center;justify-content:space-between;gap:25px;margin:0 0 24px}.toggle-row small{display:block;color:var(--muted);font-weight:400;font-size:10px;margin-top:8px}.toggle-row input{width:32px;height:18px}.session-row{display:flex;justify-content:space-between;align-items:center;padding:17px 0;border-top:1px solid var(--line);font-size:12px}.session-row small{display:block;color:var(--muted);font-size:10px;margin-top:9px}.session-row .text-button{font-size:10px}
+/* Connection, knowledge and support */
+.subscribe-panel{display:flex;justify-content:space-between;align-items:center;gap:30px;margin-bottom:35px}.subscribe-panel h2{font-size:23px;margin:17px 0 12px}.subscribe-panel p{font-size:11px;max-width:380px}.subscription-actions{display:flex;flex-direction:column;gap:12px;flex-shrink:0}.list-toolbar{display:flex;justify-content:space-between;align-items:center;gap:25px;margin:24px 0}.list-toolbar h2{font-size:18px}.list-toolbar input{max-width:240px;margin:0}.count{font:10px var(--mono);padding:4px 8px;background:#e4ebda;margin-left:10px}.node-grid,.docs-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:18px}.node-card>svg{color:#839968;width:28px;height:28px;margin-bottom:25px}.node-card h3{font-size:17px;margin-bottom:14px}.node-meta{display:flex;justify-content:space-between;border-top:1px solid var(--line);padding-top:17px;margin-top:25px;font:10px var(--mono);color:var(--muted)}.ticket-form{margin-bottom:25px}.ticket-form .panel-title h2{font-size:17px}.ticket-list{padding:0}.ticket-row{display:flex;width:100%;text-align:left;align-items:center;gap:25px;padding:25px;border-bottom:1px solid var(--line)}.ticket-row:last-child{border:0}.ticket-row>.mono{font-size:10px;color:var(--muted)}.ticket-row div{flex:1}.ticket-row b{font-size:14px}.ticket-row small{display:block;font-size:10px;margin-top:8px;color:var(--muted)}.ticket-row>svg{width:18px;height:18px}.message{margin:20px 0;border-left:2px solid #c1d99b;padding:15px 20px;background:#edf3e3}.message.from-me{background:#f0f0eb;border-color:#a6ae9b}.message>.mono{font-size:9px;color:var(--muted)}.message p{font-size:13px;line-height:1.9;margin-top:12px;white-space:pre-wrap;overflow-wrap:anywhere}.conversation .panel-title h2{font-size:20px}.article h2{font-size:28px;margin:25px 0}.doc-card .eyebrow{font-size:9px}.doc-card h2{font-size:20px;margin:24px 0 30px}.doc-card:hover{border-color:#90b951}.doc-card .text-button{font-size:11px}.invite-hero{position:relative;overflow:hidden;background:var(--ink);color:var(--paper);padding:35px;display:flex;justify-content:space-between;border-radius:4px}.invite-hero .eyebrow{color:#95a984;font-size:9px}.invite-hero h2{font-size:44px;line-height:1.45;margin:20px 0;font-weight:800;letter-spacing:-1px}.invite-hero p{font-size:12px;color:#a9b69d;margin-bottom:24px}.invite-art{font-size:150px;line-height:.75;color:var(--lime);position:absolute;right:15%;top:35px;transform:rotate(-12deg);font-weight:300}.invite-row{display:flex;justify-content:space-between;align-items:center;padding:20px 0;border-top:1px solid var(--line);gap:20px}.invite-row>.mono{font-size:18px;letter-spacing:3px}
+@media(min-width:1600px){.sidebar{width:254px;padding-left:30px;padding-right:30px}.work-main{margin-left:254px;width:calc(100% - 254px)}}
+@media(max-width:1100px){.hero-actions{align-items:flex-start;flex-direction:column;gap:12px}.hero-foot span:last-child{display:none}.hero h1{font-size:76px}.hero{min-height:630px}.orbit-svg{min-height:560px}.dashboard-grid{grid-template-columns:1.2fr 1fr;gap:16px}.panel{padding:21px}.usage-body{gap:13px}.usage-ring{width:96px;height:96px;padding:8px}.usage-numbers strong{font-size:20px}.connection-card h2{font-size:25px}.connection-bottom>.mono{display:none}.page-heading h1{font-size:31px}.sidebar{width:204px;padding-left:15px;padding-right:15px}.work-main{margin-left:204px;width:calc(100% - 204px)}.account-grid{grid-template-columns:.85fr 1.5fr}.auth-main{padding:40px 10%}.auth-art{padding:45px 10%}.subscribe-panel{align-items:flex-start}.subscription-actions .button{font-size:11px;padding:13px}.stat-strip>div{padding:23px 20px}.lower-grid{gap:16px}.quick-row p{font-size:9px}.plan-grid{grid-template-columns:repeat(auto-fit,minmax(230px,1fr))}.plan-card{padding:22px}}
+@media(max-width:850px){.dashboard-grid,.lower-grid{grid-template-columns:1fr}.usage-body{justify-content:space-between;padding:6px 25px 20px}.usage-ring{width:110px;height:110px}.usage-numbers{gap:13px}.usage-numbers strong{font-size:27px}.connection-card{min-height:260px}.connection-card h2{font-size:30px}.connection-bottom>.mono{display:block}.account-grid,.checkout-grid{grid-template-columns:1fr}.account-profile{display:none}.account-settings .panel{padding:25px}.page-heading h1{font-size:28px}.page-heading .number-tag{display:none}.subscribe-panel{flex-direction:column}.subscription-actions{flex-direction:row;width:100%}.subscription-actions .button{flex:1}.manifesto{gap:30px}.manifesto h2{font-size:30px}.start-panel h2{font-size:48px}.start-mark{font-size:245px;right:25px;top:60px}.hero h1{font-size:65px;letter-spacing:-4px}.hero-art{margin:20px}.hero-copy{padding:50px 0 0 10%}.hero .eyebrow{font-size:9px}.art-caption{left:18px;top:20px;font-size:10px}.hero-copy>p{font-size:14px}.orbit-svg{min-height:500px}.hero{min-height:590px}.brand-band{font-size:17px}.auth-layout{grid-template-columns:40% 60%}.auth-slogan{font-size:35px}.auth-main{padding:30px 10%}.auth-form h1{font-size:27px}.auth-art .brand{font-size:23px}.auth-diagram{width:220px;height:220px;right:-50px}.auth-diagram>span{top:95px;left:80px}.auth-art>.mono{font-size:7px}.field-pair{gap:13px}}
+@media(max-width:650px){.landing-nav{height:80px;padding:0 6%}.landing-nav .brand{font-size:22px}.landing-nav .brand img{width:30px;height:30px}.landing-nav nav{display:none}.nav-enter{font-size:11px;gap:12px}.hero{grid-template-columns:1fr}.hero-copy{padding:44px 7% 0}.hero h1{font-size:74px;letter-spacing:-5px}.hero-actions{flex-direction:row;align-items:center;gap:20px;margin-bottom:32px}.hero-actions .text-button{font-size:11px}.hero-copy>p{font-size:15px}.hero-art{margin:0 7% 30px;height:365px}.orbit-svg{min-height:0}.art-note{bottom:18px;font-size:8px}.brand-band{padding:20px;font-size:14px;gap:12px;overflow:hidden;white-space:nowrap}.brand-band span:last-child{display:none}.band-star{font-size:25px}.manifesto{padding:55px 7%;grid-template-columns:1fr;gap:28px}.manifesto h2{font-size:31px;margin-top:20px}.principles article{gap:20px}.principles h3{font-size:16px}.principles p{font-size:13px}.start-panel{margin:0 7% 30px;padding:33px;min-height:370px}.start-panel h2{font-size:46px;position:relative;z-index:1}.start-mark{font-size:230px;right:-15px;top:105px;opacity:.3}.start-panel .button{position:relative;z-index:1}.landing-footer{flex-wrap:wrap;gap:22px;padding:30px 7%}.landing-footer .brand{font-size:24px}.landing-footer>span:nth-child(2){order:3;width:100%;font-size:10px}.sidebar{transform:translateX(-100%);transition:transform .15s;width:232px;padding:35px 20px;box-shadow:none}.sidebar.open{transform:translateX(0);box-shadow:0 0 0 100vw #0007}.work-main{margin-left:0;width:100%}.mobile-nav{display:flex;position:fixed;top:17px;left:15px;width:34px;height:34px;align-items:center;justify-content:center;z-index:30;background:var(--paper);border-radius:3px;border:1px solid var(--line)}.work-top{height:67px;padding:0 6% 0 65px}.work-top .mono{font-size:8px}.top-help{font-size:10px;gap:5px}.top-help svg{display:none}.work-main main{padding:29px 6% 35px}.page-heading{gap:15px;margin-bottom:25px;align-items:flex-start}.page-heading h1{font-size:27px;letter-spacing:-1px;margin:13px 0 10px}.page-heading .eyebrow{font-size:8px}.page-heading p{font-size:12px}.page-heading>.button{font-size:10px;min-height:38px;padding:10px 12px;gap:5px}.page-heading>.button svg{width:13px;height:13px}.page-heading>.text-button{font-size:10px;max-width:85px}.page-heading .icon-button{margin-top:6px;flex-shrink:0}.dashboard-grid,.lower-grid{gap:18px}.connection-card{padding:24px;min-height:260px}.connection-card h2{font-size:29px;max-width:280px}.connection-top .eyebrow{font-size:8px}.connection-bottom>.mono{font-size:6px}.usage-body{justify-content:flex-start;gap:28px;padding:10px 10px 22px}.usage-ring{width:120px;height:120px}.stat-strip{grid-template-columns:1fr;margin:18px 0}.stat-strip>div{border-right:0;border-bottom:1px solid var(--line);padding:20px 23px;display:grid;grid-template-columns:1fr auto;gap:10px}.stat-strip strong{font-size:25px;grid-column:2;grid-row:1 / span 2;align-self:center}.stat-strip strong.small-value{font-size:16px;padding:0;max-width:150px;min-height:0}.stat-strip small{grid-column:1;font-size:9px}.quick-row{gap:12px}.quick-row b{font-size:12px}.quick-row p{font-size:10px}.panel-title .mono{font-size:7px}.work-footer{font-size:8px;padding:22px 6%;flex-wrap:wrap}.work-footer>span:nth-child(2){display:none}.auth-layout{grid-template-columns:1fr}.auth-art{display:none}.auth-main{min-height:100vh;padding:30px 8%}.auth-form{margin:55px auto;max-width:430px}.auth-form h1{font-size:31px}.plan-grid{grid-template-columns:1fr}.plan-card{max-width:none;padding:28px}.plan-card h2{font-size:25px}.help-strip{font-size:11px;flex-direction:column;align-items:flex-start}.checkout-grid{gap:18px}.checkout-plan,.checkout-summary{padding:25px}.period-grid button{padding:16px}.account-settings .panel{padding:24px}.field-pair{grid-template-columns:1fr;gap:0}.field-pair label{margin-top:0}.field-pair label:first-child{margin-top:20px}.toggle-row small{font-size:10px;max-width:230px;line-height:1.7}.subscribe-panel{gap:23px}.subscribe-panel h2{font-size:23px}.subscription-actions{flex-direction:column}.subscription-actions .button{font-size:12px}.list-toolbar{gap:15px}.list-toolbar input{max-width:180px;font-size:11px}.list-toolbar h2{font-size:16px;white-space:nowrap}.node-grid,.docs-grid{grid-template-columns:1fr}.ticket-form .panel-title h2{font-size:15px;line-height:1.7}.ticket-row{padding:20px;gap:12px}.ticket-row b{font-size:12px}.ticket-row .status-pill{font-size:9px}.ticket-row>svg{width:14px}.conversation{padding:23px}.conversation .panel-title h2{font-size:18px}.message{padding:15px}.invite-hero{padding:28px}.invite-hero h2{font-size:38px}.invite-art{font-size:120px;right:-15px;top:90px;opacity:.4}.invite-hero>div{position:relative;z-index:1}.invite-hero p{max-width:240px;line-height:1.8}.invite-row{gap:10px}.invite-row>.mono{font-size:15px}.invite-row .button{font-size:10px;padding:12px;gap:7px}.toast{font-size:12px;min-width:260px;max-width:90vw;padding:15px}.toast button{margin-left:auto}}
+@media(prefers-reduced-motion:reduce){*,*:before,*:after{transition:none!important;scroll-behavior:auto!important}}
+
+.mail-login{margin-top:17px;color:var(--muted);font-size:11px}
+
+.confirm-dialog{background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:32px;width:min(430px,90vw);color:var(--ink)}.confirm-dialog::backdrop{background:#101b10a6}.confirm-dialog h2{font-size:24px;margin:20px 0}.confirm-dialog p{font-size:14px;line-height:1.8;color:var(--muted);margin-bottom:28px}.confirm-dialog .form-actions{justify-content:flex-end;gap:12px}
diff --git a/frontends/hardnerd/tests/api.test.mjs b/frontends/hardnerd/tests/api.test.mjs
new file mode 100644
index 0000000..ef88b8c
--- /dev/null
+++ b/frontends/hardnerd/tests/api.test.mjs
@@ -0,0 +1,7 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';
+const storage=()=>{const values=new Map();return {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};};
+globalThis.sessionStorage=storage();globalThis.localStorage=storage();globalThis.window=new EventTarget();
+const {api,state,signIn}=await import('../src/state.mjs');
+test('API never sends credentials to an arbitrary origin',async()=>{let calls=0;globalThis.fetch=async()=>{calls++;throw new Error('unexpected HTTP');};await assert.rejects(api('//external.example/path'),/接口地址无效/);assert.equal(calls,0);});
+test('authenticated requests use same-origin APIs and preserve structured errors',async()=>{signIn('Bearer local-test');globalThis.fetch=async(path,options)=>{assert.equal(path,'/api/v1/user/info');assert.equal(options.headers.Authorization,'Bearer local-test');assert.equal(options.redirect,'error');return {ok:false,status:422,json:async()=>({message:'invalid',errors:{email:['邮箱格式不正确']}})};};await assert.rejects(api('/user/info'),/邮箱格式不正确/);});
+test('a stale expired request cannot sign out a newer session',async()=>{signIn('Bearer old-local-test');let resolve;globalThis.fetch=()=>new Promise(done=>resolve=done);const pending=api('/user/info');signIn('Bearer new-local-test');resolve({ok:false,status:401,json:async()=>({message:'Unauthorized'})});await assert.rejects(pending,/Unauthorized/);assert.equal(state.token,'Bearer new-local-test');});
diff --git a/frontends/hardnerd/tests/core.test.mjs b/frontends/hardnerd/tests/core.test.mjs
new file mode 100644
index 0000000..77f2fb5
--- /dev/null
+++ b/frontends/hardnerd/tests/core.test.mjs
@@ -0,0 +1,5 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import {money,usage,planPeriods,safeLink,statusLabel} from '../src/core.mjs';
+test('amounts stay in cents, including zero-price periods',()=>{assert.equal(money(1900),'19.00');assert.equal(money(0),'0.00');assert.deepEqual(planPeriods({month_price:0,year_price:1900,quarter_price:null}),['month_price','year_price']);});
+test('traffic meters handle empty, exhausted and over-quota subscriptions',()=>{assert.deepEqual(usage(null),{total:0,used:0,remaining:0,percent:0});assert.equal(usage({transfer_enable:100,u:70,d:50}).remaining,0);assert.equal(usage({transfer_enable:100,u:70,d:50}).percent,100);assert.equal(usage({transfer_enable:100,u:20,d:5}).percent,25);});
+test('payment links reject executable and malformed protocols',()=>{assert.equal(safeLink('javascript:alert(1)','https://hardnerd.net'),null);assert.equal(safeLink('data:text/html,payment','https://hardnerd.net'),null);assert.equal(safeLink('https://pay.example.test/order','https://hardnerd.net'),'https://pay.example.test/order');assert.equal(safeLink('/orders','https://hardnerd.net'),'https://hardnerd.net/orders');});
+test('order labels preserve backend state meanings',()=>{assert.equal(statusLabel(0),'待支付');assert.equal(statusLabel(2),'已取消');assert.equal(statusLabel(3),'已完成');assert.equal(statusLabel(999),'未知状态');});
diff --git a/frontends/hardnerd/vite.config.mjs b/frontends/hardnerd/vite.config.mjs
new file mode 100644
index 0000000..7f8f705
--- /dev/null
+++ b/frontends/hardnerd/vite.config.mjs
@@ -0,0 +1,18 @@
+import {defineConfig} from 'vite';
+import vue from '@vitejs/plugin-vue';
+import fs from 'node:fs';
+import path from 'node:path';
+import {fileURLToPath} from 'node:url';
+const root=path.dirname(fileURLToPath(import.meta.url));
+const backend=path.resolve(root,'../..');
+const marker=path.join(backend,'storage/framework/hardnerd-vite.hot');
+export default defineConfig(({mode})=>({
+ root,envDir:false,publicDir:false,base:mode==='production'?'/theme/Hardnerd/assets/':'/',
+ plugins:[vue(),{name:'hardnerd-local-preview',configureServer(server){
+ server.httpServer?.once('listening',()=>{fs.mkdirSync(path.dirname(marker),{recursive:true});fs.writeFileSync(marker,'http://127.0.0.1:5175');});
+ server.httpServer?.once('close',()=>{if(fs.existsSync(marker))fs.unlinkSync(marker);});
+ }}],
+ resolve:{dedupe:['vue']},
+ build:{outDir:path.join(backend,'theme/Hardnerd/assets'),assetsDir:'chunks',manifest:true,emptyOutDir:true,sourcemap:false},
+ server:{host:'127.0.0.1',port:5175,strictPort:true,origin:'http://127.0.0.1:5175',cors:{origin:/^http:\/\/(127\.0\.0\.1|localhost|hn\.loc)(:\d+)?$/},fs:{allow:[root,path.join(backend,'frontends/node_modules')],deny:['**/.env','**/.env.*','**/.git','**/.git/**','**/.npmrc']},proxy:{'/api':'http://127.0.0.1:8591','/brand':'http://127.0.0.1:8591'}}
+}));
diff --git a/frontends/hn/runtime-config.mjs b/frontends/hn/runtime-config.mjs
index 74c6058..ff075e8 100644
--- a/frontends/hn/runtime-config.mjs
+++ b/frontends/hn/runtime-config.mjs
@@ -13,6 +13,8 @@ export function adaptConfig(original, settings, location) {
config.DEFAULT_CONFIG.primaryColor = '#8bcf36';
config.AUTH_LAYOUT_CONFIG.splitLayout.leftContent.backgroundImage = '';
config.AUTHORIZED_DOMAINS = [location.hostname];
+ config.SHOP_CONFIG.showPlanFeatureCards = false;
+ config.SHOP_CONFIG.popup.enabled = false;
config.CLIENT_CONFIG.showDownloadCard = false;
config.CLIENT_CONFIG.clientLinks = {};
config.PROFILE_CONFIG.showRecentDevices = false;
diff --git a/frontends/hn/vite.config.mjs b/frontends/hn/vite.config.mjs
index ef503ed..6ab43b8 100644
--- a/frontends/hn/vite.config.mjs
+++ b/frontends/hn/vite.config.mjs
@@ -9,7 +9,7 @@ const upstream = path.resolve(root, '../ez-theme');
const upstreamConfig = path.join(upstream, 'src/config/index.js');
const marker = path.join(backend, 'storage/framework/ez-vite.hot');
export default defineConfig(({mode}) => ({
- root, envDir: false, base: mode === 'production' ? '/theme/EZ/assets/' : '/', publicDir: false,
+ root, envDir: false, base: mode === 'production' ? '/theme/EZ/assets/' : '/__hn_vite/', publicDir: false,
plugins: [{name:'hn-ez-adapter', enforce:'pre',
load(id) {
if (id === upstreamConfig) return `import {config as original} from ${JSON.stringify(upstreamConfig+'?hn-base')}; import {adaptConfig} from ${JSON.stringify(path.join(root,'runtime-config.mjs'))}; export const config=adaptConfig(original,window.settings||{},window.location);`;
@@ -17,12 +17,13 @@ export default defineConfig(({mode}) => ({
transform(code, id) {
if (!id.startsWith(upstream + '/src/')) return;
code = code.replaceAll('src="/images/logo.png"', ':src="hnLogo"').replaceAll('/images/logo.png', '/brand/hn/logo.svg');
+ if (id === path.join(upstream,'src/i18n/locales/zh-CN.js')) code = code.replaceAll('商店', '购买套餐');
if (id === path.join(upstream,'src/appInit.js')) code = code.replace("app.mount('#app');", "app.config.globalProperties.hnLogo = window.settings?.logo || '/brand/hn/logo.svg'; app.mount('#app');");
if (id === path.join(upstream,'src/api/user.js')) code = code.replace(/export function getIpLocationInfo\(\)\s*\{[\s\S]*?\n\}/, 'export function getIpLocationInfo() { return Promise.resolve({data: null}); }');
return code;
},
configureServer(server) {
- server.httpServer?.once('listening', () => {fs.mkdirSync(path.dirname(marker), {recursive:true}); fs.writeFileSync(marker,'http://127.0.0.1:5174');});
+ server.httpServer?.once('listening', () => {fs.mkdirSync(path.dirname(marker), {recursive:true}); fs.writeFileSync(marker,'http://hn.loc/__hn_vite');});
server.httpServer?.once('close', () => {if(fs.existsSync(marker))fs.unlinkSync(marker);});
}
}, vue({template:{transformAssetUrls:{includeAbsolute:false}}})],
@@ -31,6 +32,6 @@ export default defineConfig(({mode}) => ({
css:{preprocessorOptions:{scss:{additionalData:'@use "@/assets/styles/base/variables.scss" as *;'}}},
optimizeDeps:{include:['axios','vue','vue-router','pinia','vue-i18n']},
build:{outDir:path.join(backend,'theme/EZ/assets'),assetsDir:'chunks',manifest:true,emptyOutDir:true,sourcemap:false},
- server:{host:'127.0.0.1',port:5174,strictPort:true,origin:'http://127.0.0.1:5174',cors:{origin:/^http:\/\/(127\.0\.0\.1|localhost|hn\.loc)(:\d+)?$/},
- fs:{allow:[path.resolve(root,'..')],deny:['**/.env','**/.env.*','**/.git','**/.git/**','**/.npmrc']},proxy:{'/api':'http://127.0.0.1:8591','/brand':'http://127.0.0.1:8591'}}
+ server:{host:'127.0.0.1',port:5184,strictPort:true,origin:'http://hn.loc',allowedHosts:['hn.loc'],hmr:{host:'hn.loc',clientPort:80},cors:{origin:/^http:\/\/(127\.0\.0\.1|localhost|hn\.loc)(:\d+)?$/},
+ fs:{allow:[path.resolve(root,'..')],deny:['**/.env','**/.env.*','**/.git','**/.git/**','**/.npmrc']},proxy:{'/api':'http://127.0.0.1:8592','/brand':'http://127.0.0.1:8592'}}
}));
diff --git a/frontends/package.json b/frontends/package.json
index feda1dc..299a493 100644
--- a/frontends/package.json
+++ b/frontends/package.json
@@ -5,7 +5,14 @@
"scripts": {
"dev:hn": "vite --config hn/vite.config.mjs",
"build:hn": "vite build --config hn/vite.config.mjs",
- "test:hn": "node --test hn/tests/*.test.mjs"
+ "test:hn": "node --test hn/tests/*.test.mjs",
+ "dev:hardnerd": "vite --config hardnerd/vite.config.mjs",
+ "build:hardnerd": "vite build --config hardnerd/vite.config.mjs",
+ "test:hardnerd": "node --test hardnerd/tests/*.test.mjs",
+ "dev:design": "npm run dev:tpl-a",
+ "build:design": "npm run build:tpl-a",
+ "dev:tpl-a": "npm --prefix tpl-a run dev",
+ "build:tpl-a": "npm --prefix tpl-a run build"
},
"keywords": [],
"author": "",
diff --git a/scripts/hn/publish-assets.php b/scripts/hn/publish-assets.php
index 7365cc2..a216a7a 100644
--- a/scripts/hn/publish-assets.php
+++ b/scripts/hn/publish-assets.php
@@ -1,16 +1,18 @@
make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
if (!App\Support\SiteProfile::isHn()) {
- throw new RuntimeException('EZ asset publication requires SITE_PROFILE=hn.');
+ throw new RuntimeException('hn asset publication requires SITE_PROFILE=hn.');
}
-if (!is_file(base_path('theme/EZ/assets/.vite/manifest.json'))) {
- throw new RuntimeException('Build EZ first: cd frontends && npm ci && npm run build:hn');
+$theme = config('site.theme', 'EZ');
+$build = $theme === 'EZ' ? 'build:hn' : 'build:hardnerd';
+if (!is_file(base_path('theme/'.$theme.'/assets/.vite/manifest.json'))) {
+ throw new RuntimeException('Build '.$theme.' first: cd frontends && npm ci && npm run '.$build);
}
-if (!Illuminate\Support\Facades\File::copyDirectory(base_path('theme/EZ'), public_path('theme/EZ'))) {
- throw new RuntimeException('Failed to publish EZ assets.');
+if (!Illuminate\Support\Facades\File::copyDirectory(base_path('theme/'.$theme), public_path('theme/'.$theme))) {
+ throw new RuntimeException('Failed to publish '.$theme.' assets.');
}
-echo "EZ assets published for hn.\n";
+echo $theme." assets published for hn.\n";
diff --git a/tests/Feature/SiteProfile/SiteProfileTest.php b/tests/Feature/SiteProfile/SiteProfileTest.php
index da1e2f5..0403d20 100644
--- a/tests/Feature/SiteProfile/SiteProfileTest.php
+++ b/tests/Feature/SiteProfile/SiteProfileTest.php
@@ -135,6 +135,39 @@ class SiteProfileTest extends TestCase
$this->getJson($url)->assertForbidden();
}
+ public function test_ez_is_available_as_an_explicit_hn_comparison_theme(): void
+ {
+ config(['site.profile'=>'hn','site.theme'=>'EZ']);
+ $this->assertSame('EZ', SiteProfile::publicSettings()['frontend_theme']);
+ SiteProfile::assertSettingsWritable(['frontend_theme'=>'EZ']);
+ }
+
+ public function test_custom_theme_never_uses_dev_assets_for_a_public_hostname(): void
+ {
+ try {
+ $assets = \App\Support\HardnerdAssets::scripts(\Illuminate\Http\Request::create('https://hardnerd.net/'));
+ foreach ($assets['scripts'] as $script) $this->assertStringStartsWith('/theme/Hardnerd/assets/', $script);
+ } catch (\RuntimeException $exception) {
+ $this->assertStringContainsString('Hardnerd assets missing', $exception->getMessage());
+ }
+ }
+
+ public function test_hn_rejects_an_unknown_theme_selector(): void
+ {
+ $_ENV['SITE_PROFILE']=$_SERVER['SITE_PROFILE']='hn';
+ $_ENV['SITE_THEME']=$_SERVER['SITE_THEME']='Stellar';
+ $this->expectException(\InvalidArgumentException::class);
+ require base_path('config/site.php');
+ }
+
+ public function test_dpl_does_not_apply_the_hn_theme_selector(): void
+ {
+ $_ENV['SITE_PROFILE']=$_SERVER['SITE_PROFILE']='dpl';
+ $_ENV['SITE_THEME']=$_SERVER['SITE_THEME']='Stellar';
+ $this->assertSame('dpl',(require base_path('config/site.php'))['profile']);
+ $this->assertSame([], SiteProfile::publicSettings());
+ }
+
public function test_invalid_selector_fails_instead_of_silently_selecting_dpl(): void
{
$old = $_ENV['SITE_PROFILE'] ?? null;
diff --git a/theme/.gitignore b/theme/.gitignore
index 87b71ac..25ff0fa 100644
--- a/theme/.gitignore
+++ b/theme/.gitignore
@@ -4,3 +4,5 @@
!.gitignore
!EZ
+
+!Hardnerd
diff --git a/theme/Hardnerd/.gitignore b/theme/Hardnerd/.gitignore
new file mode 100644
index 0000000..7cf58ac
--- /dev/null
+++ b/theme/Hardnerd/.gitignore
@@ -0,0 +1 @@
+/assets/
diff --git a/theme/Hardnerd/config.json b/theme/Hardnerd/config.json
new file mode 100644
index 0000000..195bbd5
--- /dev/null
+++ b/theme/Hardnerd/config.json
@@ -0,0 +1,6 @@
+{
+ "name": "Hardnerd",
+ "description": "hardnerd 定制主题 · 独立设计与前台源码",
+ "version": "1.0.0",
+ "configs": []
+}
diff --git a/theme/Hardnerd/dashboard.blade.php b/theme/Hardnerd/dashboard.blade.php
new file mode 100644
index 0000000..66029a8
--- /dev/null
+++ b/theme/Hardnerd/dashboard.blade.php
@@ -0,0 +1,13 @@
+@php($assets = \App\Support\HardnerdAssets::scripts(request()))
+
+
+{{ $title }}
+
+@foreach ($assets['styles'] as $style)
+
+@endforeach
+
+@foreach ($assets['scripts'] as $script)
+
+@endforeach
+