From 44f3f950d7ee70b33020aa0dc6b962828d595d27 Mon Sep 17 00:00:00 2001 From: muyuzhong <2416118206@qq.com> Date: Sat, 24 Oct 2026 19:08:43 +0800 Subject: [PATCH 1/2] feat(ui): redesign launcher as a deconstructed construction drawing Replace the particle sidebar layout with a construction-drawing shell: an exposed construction grid with generated axis labels, a frameless window with a custom title bar, oversized titles with a dropped glyph, overlapping system windows, a dithered sky and hidden base64 easter eggs. The shadcn primitives are restyled so wizards and dialogs follow. The interface uses fixed CSS pixels like the official Minecraft launcher: it follows only the system display scale, never the window size. Extra window space goes to the grid, the sky and whitespace; content that does not fit scrolls inside its own column. The minimum window stays at 905x575 and the window ratio is not locked. Bundle Inter, Inter Tight, JetBrains Mono and Departure Mono locally, refresh the Linux UI snapshot baselines, and add a test that keeps the home title, launch button and navigation the same size across five window sizes. Reviewed-by: Claude Opus 5.5 Co-Authored-By: Claude Opus 5.5 --- .changes/ui-deconstruct-redesign.md | 6 + packages/ui/design/DECONSTRUCT.md | 150 ++++ packages/ui/package.json | 3 + .../DepartureMono-Regular.woff2 | Bin 0 -> 22496 bytes .../src/assets/fonts/departure-mono/LICENSE | 93 +++ packages/ui/src/components/bottom-bar.tsx | 5 +- packages/ui/src/components/config-editor.tsx | 6 +- .../ui/src/components/conversion-wizard.tsx | 34 +- .../ui/src/components/deco/display-title.tsx | 66 ++ .../ui/src/components/deco/dither-sky.tsx | 168 ++++ packages/ui/src/components/deco/marks.tsx | 326 ++++++++ .../ui/src/components/deco/page-header.tsx | 69 ++ packages/ui/src/components/deco/widgets.tsx | 165 ++++ packages/ui/src/components/deco/win.tsx | 46 ++ .../ui/src/components/download-monitor.tsx | 53 +- packages/ui/src/components/guided-tour.tsx | 2 +- packages/ui/src/components/import-wizard.tsx | 25 +- .../src/components/instance-editor-modal.tsx | 84 +- .../components/memory-allocation-panel.tsx | 28 +- .../ui/src/components/particle-background.tsx | 55 -- .../components/shell/construction-grid.tsx | 120 +++ .../ui/src/components/shell/easter-eggs.tsx | 214 ++++++ packages/ui/src/components/shell/logo.tsx | 58 ++ packages/ui/src/components/shell/stage.tsx | 18 + .../ui/src/components/shell/status-strip.tsx | 85 ++ packages/ui/src/components/shell/top-nav.tsx | 243 ++++++ packages/ui/src/components/sidebar.tsx | 273 ------- packages/ui/src/components/ui/accordion.tsx | 4 +- .../ui/src/components/ui/alert-dialog.tsx | 18 +- packages/ui/src/components/ui/avatar.tsx | 10 +- packages/ui/src/components/ui/badge.tsx | 11 +- packages/ui/src/components/ui/button.tsx | 30 +- packages/ui/src/components/ui/card.tsx | 4 +- packages/ui/src/components/ui/checkbox.tsx | 2 +- packages/ui/src/components/ui/dialog.tsx | 32 +- .../ui/src/components/ui/dropdown-menu.tsx | 15 +- packages/ui/src/components/ui/field.tsx | 4 +- packages/ui/src/components/ui/input.tsx | 2 +- packages/ui/src/components/ui/radio-group.tsx | 4 +- packages/ui/src/components/ui/scroll-area.tsx | 4 +- packages/ui/src/components/ui/select.tsx | 16 +- packages/ui/src/components/ui/spinner.tsx | 27 +- packages/ui/src/components/ui/switch.tsx | 5 +- packages/ui/src/components/ui/tabs.tsx | 18 +- packages/ui/src/components/ui/textarea.tsx | 2 +- packages/ui/src/components/user-avatar.tsx | 3 +- packages/ui/src/index.css | 654 +++++++++++++--- packages/ui/src/lib/desktop-window.ts | 92 +++ packages/ui/src/lib/effects/saturn.ts | 281 ------- packages/ui/src/lib/eggs.ts | 77 ++ packages/ui/src/locales/en.json | 21 +- packages/ui/src/locales/zh-CN.json | 21 +- packages/ui/src/main.tsx | 10 +- packages/ui/src/pages/assistant.tsx | 336 ++++---- packages/ui/src/pages/home.tsx | 723 +++++++++--------- packages/ui/src/pages/index.tsx | 90 +-- packages/ui/src/pages/instances/browse.tsx | 46 +- packages/ui/src/pages/instances/create.tsx | 56 +- packages/ui/src/pages/instances/index.tsx | 716 +++++++++-------- packages/ui/src/pages/instances/mods.tsx | 32 +- packages/ui/src/pages/settings.tsx | 113 ++- .../default-window/assistant-offline-dark.png | Bin 223381 -> 129377 bytes .../assistant-offline-light.png | Bin 256588 -> 128459 bytes .../default-window/assistant-ready-dark.png | Bin 218087 -> 132039 bytes .../default-window/assistant-ready-light.png | Bin 251812 -> 131049 bytes .../default-window/config-editor-dark.png | Bin 91416 -> 111891 bytes .../default-window/config-editor-light.png | Bin 108386 -> 110529 bytes .../linux/default-window/downloading-dark.png | Bin 220016 -> 151677 bytes .../default-window/downloading-light.png | Bin 254058 -> 151892 bytes .../linux/default-window/error-dark.png | Bin 204609 -> 150922 bytes .../linux/default-window/error-light.png | Bin 237562 -> 151228 bytes .../linux/default-window/failed-dark.png | Bin 206250 -> 158832 bytes .../linux/default-window/failed-light.png | Bin 242913 -> 159204 bytes .../default-window/instances-20-dark.png | Bin 237499 -> 118520 bytes .../default-window/instances-20-light.png | Bin 269703 -> 116941 bytes .../default-window/instances-empty-dark.png | Bin 211841 -> 73084 bytes .../default-window/instances-empty-light.png | Bin 256022 -> 72925 bytes .../default-window/instances-error-dark.png | Bin 255813 -> 65343 bytes .../default-window/instances-error-light.png | Bin 302632 -> 65879 bytes .../default-window/instances-grid-dark.png | Bin 233848 -> 102418 bytes .../default-window/instances-grid-light.png | Bin 265493 -> 101626 bytes .../linux/default-window/launching-dark.png | Bin 209243 -> 149339 bytes .../linux/default-window/launching-light.png | Bin 245747 -> 149212 bytes .../default-window/memory-pressure-dark.png | Bin 208419 -> 145510 bytes .../default-window/memory-pressure-light.png | Bin 245057 -> 145782 bytes .../default-window/memory-settings-dark.png | Bin 163382 -> 105942 bytes .../default-window/memory-settings-light.png | Bin 191388 -> 106676 bytes .../linux/default-window/migration-dark.png | Bin 73395 -> 65641 bytes .../linux/default-window/migration-light.png | Bin 104867 -> 67304 bytes .../linux/default-window/no-account-dark.png | Bin 178259 -> 142963 bytes .../linux/default-window/no-account-light.png | Bin 216692 -> 143005 bytes .../linux/default-window/no-instance-dark.png | Bin 211117 -> 145554 bytes .../default-window/no-instance-light.png | Bin 247097 -> 145799 bytes .../linux/default-window/not-ready-dark.png | Bin 210263 -> 146432 bytes .../linux/default-window/not-ready-light.png | Bin 246359 -> 146858 bytes .../linux/default-window/ready-dark.png | Bin 207966 -> 146534 bytes .../linux/default-window/ready-light.png | Bin 244522 -> 146535 bytes .../linux/default-window/running-dark.png | Bin 210009 -> 150340 bytes .../linux/default-window/running-light.png | Bin 246832 -> 150544 bytes .../linux/default-window/stopped-dark.png | Bin 210081 -> 149468 bytes .../linux/default-window/stopped-light.png | Bin 246418 -> 149318 bytes .../minimum-window/assistant-offline-dark.png | Bin 168117 -> 84730 bytes .../assistant-offline-light.png | Bin 194703 -> 84313 bytes .../minimum-window/assistant-ready-dark.png | Bin 168133 -> 90032 bytes .../minimum-window/assistant-ready-light.png | Bin 195166 -> 89409 bytes .../minimum-window/config-editor-dark.png | Bin 70489 -> 81424 bytes .../minimum-window/config-editor-light.png | Bin 83551 -> 80905 bytes .../linux/minimum-window/downloading-dark.png | Bin 169685 -> 115319 bytes .../minimum-window/downloading-light.png | Bin 193180 -> 115666 bytes .../linux/minimum-window/error-dark.png | Bin 158329 -> 114713 bytes .../linux/minimum-window/error-light.png | Bin 180363 -> 115082 bytes .../linux/minimum-window/failed-dark.png | Bin 163113 -> 120728 bytes .../linux/minimum-window/failed-light.png | Bin 186094 -> 121356 bytes .../minimum-window/instances-20-dark.png | Bin 187817 -> 87665 bytes .../minimum-window/instances-20-light.png | Bin 213440 -> 87028 bytes .../minimum-window/instances-empty-dark.png | Bin 162337 -> 63177 bytes .../minimum-window/instances-empty-light.png | Bin 196761 -> 62966 bytes .../minimum-window/instances-error-dark.png | Bin 213886 -> 54854 bytes .../minimum-window/instances-error-light.png | Bin 247311 -> 54781 bytes .../minimum-window/instances-grid-dark.png | Bin 165988 -> 73384 bytes .../minimum-window/instances-grid-light.png | Bin 192267 -> 72996 bytes .../linux/minimum-window/launching-dark.png | Bin 161666 -> 113086 bytes .../linux/minimum-window/launching-light.png | Bin 187392 -> 113074 bytes .../minimum-window/memory-pressure-dark.png | Bin 160403 -> 109582 bytes .../minimum-window/memory-pressure-light.png | Bin 184093 -> 109888 bytes .../minimum-window/memory-settings-dark.png | Bin 140268 -> 70369 bytes .../minimum-window/memory-settings-light.png | Bin 163881 -> 71131 bytes .../linux/minimum-window/migration-dark.png | Bin 44076 -> 49441 bytes .../linux/minimum-window/migration-light.png | Bin 56803 -> 49633 bytes .../linux/minimum-window/no-account-dark.png | Bin 134173 -> 108975 bytes .../linux/minimum-window/no-account-light.png | Bin 160924 -> 109022 bytes .../linux/minimum-window/no-instance-dark.png | Bin 162629 -> 110294 bytes .../minimum-window/no-instance-light.png | Bin 185398 -> 110311 bytes .../linux/minimum-window/not-ready-dark.png | Bin 161847 -> 110738 bytes .../linux/minimum-window/not-ready-light.png | Bin 185176 -> 111197 bytes .../linux/minimum-window/ready-dark.png | Bin 160159 -> 110794 bytes .../linux/minimum-window/ready-light.png | Bin 186036 -> 111086 bytes .../linux/minimum-window/running-dark.png | Bin 161849 -> 114228 bytes .../linux/minimum-window/running-light.png | Bin 185396 -> 114664 bytes .../linux/minimum-window/stopped-dark.png | Bin 162152 -> 113592 bytes .../linux/minimum-window/stopped-light.png | Bin 187727 -> 113495 bytes packages/ui/tests/launcher-fixtures.spec.ts | 104 +++ pnpm-lock.yaml | 24 + src-tauri/capabilities/default.json | 4 + src-tauri/tauri.conf.json | 3 +- 145 files changed, 4117 insertions(+), 1892 deletions(-) create mode 100644 .changes/ui-deconstruct-redesign.md create mode 100644 packages/ui/design/DECONSTRUCT.md create mode 100644 packages/ui/src/assets/fonts/departure-mono/DepartureMono-Regular.woff2 create mode 100644 packages/ui/src/assets/fonts/departure-mono/LICENSE create mode 100644 packages/ui/src/components/deco/display-title.tsx create mode 100644 packages/ui/src/components/deco/dither-sky.tsx create mode 100644 packages/ui/src/components/deco/marks.tsx create mode 100644 packages/ui/src/components/deco/page-header.tsx create mode 100644 packages/ui/src/components/deco/widgets.tsx create mode 100644 packages/ui/src/components/deco/win.tsx delete mode 100644 packages/ui/src/components/particle-background.tsx create mode 100644 packages/ui/src/components/shell/construction-grid.tsx create mode 100644 packages/ui/src/components/shell/easter-eggs.tsx create mode 100644 packages/ui/src/components/shell/logo.tsx create mode 100644 packages/ui/src/components/shell/stage.tsx create mode 100644 packages/ui/src/components/shell/status-strip.tsx create mode 100644 packages/ui/src/components/shell/top-nav.tsx delete mode 100644 packages/ui/src/components/sidebar.tsx create mode 100644 packages/ui/src/lib/desktop-window.ts delete mode 100644 packages/ui/src/lib/effects/saturn.ts create mode 100644 packages/ui/src/lib/eggs.ts diff --git a/.changes/ui-deconstruct-redesign.md b/.changes/ui-deconstruct-redesign.md new file mode 100644 index 00000000..1ea68bbb --- /dev/null +++ b/.changes/ui-deconstruct-redesign.md @@ -0,0 +1,6 @@ +--- +"dropout": "patch:feat" +"@dropout/ui": "patch:feat" +--- + +Redesign the launcher as a deconstructed construction drawing with a frameless window, an exposed construction grid, oversized titles with a dropped glyph, overlapping system windows, a dithered sky, and hidden base64 easter eggs. The interface uses fixed pixels like the official Minecraft launcher: it follows only the system display scale, never the window size, and extra window space goes to the grid and whitespace. diff --git a/packages/ui/design/DECONSTRUCT.md b/packages/ui/design/DECONSTRUCT.md new file mode 100644 index 00000000..580fb26e --- /dev/null +++ b/packages/ui/design/DECONSTRUCT.md @@ -0,0 +1,150 @@ +# DropOut 界面重设计 · 解构 + +> 分支 `feat/ui-deconstruct`。粒子方块世界那一版(`feat/ui-redesign-prototype`)原样保留,不在这条分支上。 +> 参考:TypeSafe AI 官网(jev)。只借它的语法,不抄它的品牌。 + +## 0. 一句话 + +把启动器画成一张还没收拾干净的施工图:网格、轴号、裁切角、尺寸线都露在外面; +超大的字被切开、错位,其中一个字被「丢掉」只剩轮廓;窗口互相压着、微微歪斜; +有些文字表面上是一串 base64,复制下来解码才读得到。 + +DropOut 这个名字本来就是神经网络里的 dropout:训练时随机丢掉一部分神经元,剩下的更强。 +所以「丢掉」是贯穿全局的母题:被丢掉的字、被丢掉的方块、被丢掉的神经元,都只剩一圈虚线。 + +## 1. 为什么换掉粒子 + +维护者觉得粒子太花。粒子版的主角是一团会动的东西,界面退到细线和字后面; +这一版反过来:**没有主角,结构本身就是画面**。动的东西只剩两处小部件,都很慢, +而且在减弱动效和测试夹具里完全静止。 + +## 2. 尺寸规则(照官方启动器) + +官方 Minecraft 启动器的界面是**固定尺寸**的:字号、按钮、栏宽都是固定像素,只跟系统显示缩放(125%、150%)走 +(参考 Mojang 的 MCL-10940);窗口随便拉,不锁比例,只有最小尺寸;窗口变大时多出来的地方给画面, +界面本身不变大。具体像素数官方没公开,这里照的是规则。 + +- 界面全部是固定 CSS 像素:不用 `transform: scale()`,不调 webview 的 `setZoom()`,字号不按窗口宽高算(不用 `cqw`/`vw` 字号)。 + 系统缩放由 webview 的 `devicePixelRatio` 处理,不用写代码。 +- 窗口最小 905×575(`src-tauri/tauri.conf.json`),不锁比例、不吸附。无边框窗口的拖动、最小化 / 最大化 / 关闭、 + 右键菜单在 `src/lib/desktop-window.ts`;`src/components/shell/stage.tsx` 只是一个铺满窗口的根节点。 +- 窗口变大,多出来的空间这样用: + + | 部分 | 规则 | + | ---- | ---- | + | 施工网格 | 格子固定约 84×89(默认窗口内容区 1006×711 正好 12 × 8 格),铺满窗口,窗口越大格子越多;轴号按格数生成,列号 Z 之后接 AA、AB… | + | 首页 | 左栏固定 551 宽贴左,右边那叠窗口固定 388 宽贴右,中间是留白;抖色天空铺满宽度,高度固定 355.5 | + | 实例页 | 列表伸缩,右边详情栏固定 394;网格视图每张卡至少 260,窗口越宽列越多 | + | 助手页 | 对话区伸缩(气泡最宽 720),右边上下文栏固定 292 | + | 设置页 | 版心最宽 1006(默认窗口里的宽度),贴左,不跟着变宽 | + | 章节大数字 | 02 / 03 / 04 的右边缘钉在默认窗口里的位置,窗口窄时往左让,不压右上角的按钮 | + +- 放不下的时候在自己那一栏里滚动,不缩小字。最小窗口下首页右栏变窄(最窄约 268), + 两个窗口的错位收小,DAYLIGHT 小部件收起,页面纵向滚动。 +- 弹出层(对话框、下拉、菜单)挂在 body 上;对话框「不超过窗口」的约束用 `vh`/`vw`,这不算缩放。 +- 状态栏和 F3 的坐标就是窗口坐标;F3 里写窗口尺寸和 devicePixelRatio。 + +## 3. 解构手法对照 + +| 手法 | 在界面里的样子 | +| ---- | ---- | +| 暴露结构 | 全局施工网格(默认窗口 12 列 × 8 行,顶上 A–L、左边 1–8 轴号,窗口大了格子更多),四角套准记号 ⊕;首页标题下方一条实时量出来的尺寸线 `W 551` | +| 碎片 / 错位 | 标题除第一行外整体缩进半格;每句标题按固定规则丢掉一个字(只剩描边),并拉一条引线标注 `DROPPED · p=0.50` | +| 叠压 / 倾斜 | 首页右侧 INSTANCE.CFG、READINESS.SYS、两个小部件互相压着,小部件转 −2.2° / 1.6°;一块旋转的虚线框「被丢掉的方块」压在标题后面 | +| 印刷错版 | 当前实例、按钮悬停、输入框聚焦时,身后露出一道偏移的粉色色块 | +| 巨大的章节号 | 实例 02、助手 03、设置 04 的页头后面压一个空心大号数字,被页面切掉一部分 | +| 第二层文本 | 侧边竖排密文、`[B.64]` 块、状态栏中间那串,见第 7 节 | +| 字体冲突 | 挤紧的无衬线大字 + 像素等宽小字 + 等宽说明字,三种声音同时说话 | + +## 4. 从 jev 借来的 + +- 白色标签贴在色场上的导航(当前页反成黑底)。 +- 抖色(dither)色场:首页上半截是一块静态的像素云,往下散成纸面。 +- 复古系统窗口:黑色标题栏 + 像素字「文件名」(`INSTANCE.CFG`、`SESSION.LOG`…),1px 墨线、硬投影。 +- 引导点线 `标签 ……… 数值`、左边一道竖线的文字栏、`∵ x` 分节记号、分段进度条。 +- 超大的下划线链接当主操作(`▶▶▶ Launch`)。 +- 竖排 base64、`[B.64]` 块、页脚 base64 这类彩蛋。 + +jev 自己用的 Die Grotesk 和 LisaTerminal 是商业字体,这里换成开源替代(见第 5 节)。 + +## 5. 配色与字体 + +| 记号 | 纸面(浅) | 墨面(深) | 用途 | +| ---- | ---- | ---- | ---- | +| `paper` | `#eeece7` | `#171717` | 背景 | +| `sheet` | `#fbfaf7` | `#1f1f1f` | 窗口、卡片 | +| `ink` / `rule` | `#1e1e1e` | `#eceae4` | 文字、1px 墨线 | +| `win` | `#dedede` | `#2c2c2c` | 窗口里的灰色控制区 | +| `signal` | `#f386a1` | 同 | 错版色块、当前项小方块 | +| `go` | `#43b581` | `#4cc48c` | 启动、就绪(DropOut 图标原本的绿) | +| `sky` / `warn` / `fault` | 蓝 / 黄 / 红 | 同 | 天空、警告、错误 | + +`go-ink` / `warn-ink` / `fault-ink` 是同色系的深色版,专门给文字用,保证对比度。 +圆角全部为 0。shadcn 的 `primary` 映射成墨色,`accent`(菜单高亮)是反白。 + +字体全部本地打包,不走 CDN(Tauri 的 CSP 也不允许): + +- 大标题:Inter Tight(`type-display`,字距 −0.055em,行距 0.86;中文自动放宽) +- 正文:Inter +- 像素小字:Departure Mono(OFL,文件和授权在 `src/assets/fonts/departure-mono/`) +- 等宽:JetBrains Mono +- 中文回落到系统字体(苹方 / 雅黑 / Noto Sans CJK) + +## 6. 构件 + +```text +src/index.css 记号、字体、.win/.win-title/.tag/.chip/.crop/.dots/.leader/.dropped/.misreg 等 +src/lib/desktop-window.ts 无边框窗口:拖动、最小化 / 最大化 / 关闭、右键菜单 +src/components/shell/ + stage.tsx 铺满窗口的根节点 + top-nav.tsx 顶栏标签导航 + 账号 + construction-grid.tsx 施工网格(固定格子)、按格数生成的标尺、竖排密文 + status-strip.tsx 底栏(系统名、版本、路由、base64、鼠标坐标) + easter-eggs.tsx ↑↑↓↓←→←→BA 解构模式(左下角提示窗)、F3 调试信息、控制台招呼 + logo.tsx 标志(神经网络,两个节点被丢掉)+ 字标 Drop○ut +src/components/deco/ + win.tsx 系统窗口 + display-title.tsx 丢一个字的超大标题 + dither-sky.tsx 抖色天空(静态,按尺寸和主题重画) + widgets.tsx DROPOUT 神经元小部件、DAYLIGHT 游戏时钟 + marks.tsx 引导点线、分段条、尺寸线、引线标注、套准记号、[B.64]、竖排密文 + page-header.tsx 二级页面页头 +src/lib/eggs.ts 所有彩蛋的内容 +``` + +shadcn 基础组件(按钮、对话框、下拉、选择、选项卡、开关…)直接改了样式, +所以向导、编辑器、弹窗这些没单独重写的地方也跟着换了语言。对话框统一成系统窗口, +标题栏默认写 `DIALOG.SYS`,可以用 `chrome` 换。 + +## 7. 彩蛋 + +| 位置 | 内容 | +| ---- | ---- | +| 左侧竖排那串 | base64 套了六层,最后是 *Never dig straight down.* | +| 设置页右上 `[B.64]` | dropout 本身的代码(Srivastava 2014),注释 *drop half. the rest get stronger.* | +| 助手页底部 `[B.64]` | 一张 16×16 灰度 PNG:苦力怕的脸(对应 jev 藏的 MNIST 手写数字) | +| 状态栏中间 | *DropOut, the next-generation Minecraft launcher.* | +| ↑↑↓↓←→←→BA | 解构模式:所有盒子的边界都描出来,背景抽掉,露出层层叠压。左下角(首页日志窗下面那块空白)弹出一扇固定 360×96 的 `DECONSTRUCT.EXE` 小窗,带图例;再输一次、按 Esc 或点 × 退出 | +| F3 | Minecraft 式调试信息(帧率、坐标、区块、路由、实例) | +| DROPOUT 小部件 | 点一下换丢弃率 p(0.1 → 0.9) | +| DAYLIGHT 小部件 | 现实时间换算成游戏时间,第几天从 Minecraft 正式版发布那天算 | +| 浏览器控制台 | 一行像素字 DROP OUT 和上面两个按键提示 | + +## 8. 测试 + +- 功能测试全部通过(可访问性、键盘、各种启动状态、迁移、助手…)。 +- 新增「界面不随窗口缩放」:905×575、1024×768、1280×720、1920×1080、2560×1440 五种窗口下, + 首页标题、启动按钮、导航的 computed font-size 和尺寸完全一样,左栏和右栏不重叠,没有横向滚动。 +- 助手页图片彩蛋那条:最小窗口里它在页面折线以下,测试先把它滚到中间,再检查完整可见、能复制。 +- 截图基线全部重画:只重新生成了 Linux 的(CI 跑在 Ubuntu 上),macOS 的旧基线没动。 + - default-window(1024×768)基本不变,只有字号取整带来的亚像素差异(标题 73.7 → 74 这类);网格和天空逐像素一致。 + - minimum-window 改回 905×575,从「整张画板缩小」变成 1:1 排版,放不下的部分要滚动才看得到。 +- 截图要逐像素一致,所以夹具模式下:时钟固定在一个时刻、DROPOUT 小部件不跳、抖色天空用固定种子。 +- 系统缩放:用 `deviceScaleFactor: 1.5` 截图,整个界面按 1.5 倍放大,排版和 1 倍一样。 + +## 9. 没做 / 下一步 + +1. Tauri 窗口里的 WebKitGTK 还没实测(抖色天空是 2D canvas,风险比粒子小得多);拉窗口、最大化也只在浏览器里按尺寸验过。 +2. 浏览页(Modrinth)只换了页头和列表行,卡片可以再做得更像「目录卡」。 +3. 实例编辑弹窗、导入/转换向导只换了基础组件和颜色,没有逐屏打磨。 +4. 设置里可以加一个「施工网格」开关,给觉得线太多的人关掉。 diff --git a/packages/ui/package.json b/packages/ui/package.json index 3f730ff3..41c5a115 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,6 +14,9 @@ }, "dependencies": { "@base-ui/react": "^1.8.0", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/inter-tight": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@hookform/resolvers": "^5.9.1", "@stepperize/react": "^8.0.0", "@tauri-apps/api": "^2.11.1", diff --git a/packages/ui/src/assets/fonts/departure-mono/DepartureMono-Regular.woff2 b/packages/ui/src/assets/fonts/departure-mono/DepartureMono-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b4a23dc1dad1c934005d0862e62c0294636afb08 GIT binary patch literal 22496 zcmV)CK*GOwPew9NR8&s@09W7u3;+NC0ZIS>09TLz0RR9100000000000000000000 z0000DgW3=pg<>0&ZXA{r24Dc{ZUHs|BmCrObD{ZH=hZA*&!MH+zI zVgQwY&Xv7oaDi>piFnp|L%N=YblB`*3>zna;rXqz|NsC0|NsA$%a_OIlC<^Z{4FiW z5Mlt1+f*Rat075p$4JQ#bNd8rcH26dxfJf!f+<%oEMz zWCgC9uTrU$ItyD;yOMObCg-bfw%u*mK7!m2%)d)VRdOn3#+A|-Mt04Ut=LICW2s;* zA|kjunV{LHI~RKYoW#P3Pt@LQT4+s0e=T}E+&xIyQo1A03H+%fHu;|>Y$AJil-e?cEWRiLUXmj*mfu(sZxSrLgc z3UQ4_M8v{EL_{UqAloiX%(DwK`pOJ?kg?Z$B~@2Flv6;G6oRIB0!M0@T;_;4eoB$- zF93o0*DXJR1UWUCyY+QghEC^#Tf~46#1cdapi}R6(v789DI^^mXcHEc%m33a``
S&Y$B6bQNVy_}t=+?^%B}Y(` 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 v7oUo5C)>^E>&CZV$&|CqZZE~UofZ=oq - +
@@ -238,7 +238,7 @@ export function ConfigEditor({ onOpenChange, ...props }: ConfigEditorProps) { {isDirty && (
{t("conversion.report.source")}
-
+
{t("conversion.report.unchanged")}
-
+
{rolledBack ? t("conversion.report.rolledBack") @@ -441,7 +441,7 @@ export function ConversionWizard({ className="p-4 sm:p-5" aria-label={t("conversion.source.label")} > -

+

{t("conversion.source.label")}

@@ -452,13 +452,13 @@ export function ConversionWizard({ {instance?.modLoader ?? "vanilla"}{" "} {instance?.modLoaderVersion ?? ""}

-

+

{t("conversion.source.protected")}

-
+
@@ -466,7 +466,7 @@ export function ConversionWizard({ className="p-4 sm:p-5" aria-label={t("conversion.target.label")} > -

+

{t("conversion.target.label")}

@@ -698,7 +698,7 @@ export function ConversionWizard({
{preview && unresolvedCount > 0 && ( - + {t("conversion.blocked", { count: unresolvedCount })} )} diff --git a/packages/ui/src/components/deco/display-title.tsx b/packages/ui/src/components/deco/display-title.tsx new file mode 100644 index 00000000..efddbd89 --- /dev/null +++ b/packages/ui/src/components/deco/display-title.tsx @@ -0,0 +1,66 @@ +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +function hash(text: string) { + let value = 2166136261; + for (const char of text) { + value ^= char.codePointAt(0) ?? 0; + value = Math.imul(value, 16777619); + } + return value >>> 0; +} + +/** 选一个要「丢掉」的字:不选第一个字,不选空格和标点;同一句话总是丢同一个。 */ +export function droppedGlyphIndex(text: string) { + const glyphs = Array.from(text); + const candidates = glyphs + .map((glyph, index) => ({ glyph, index })) + .filter(({ glyph, index }) => index > 0 && /\p{L}/u.test(glyph)) + .map(({ index }) => index); + if (glyphs.length < 4 || candidates.length === 0) return -1; + return candidates[hash(text) % candidates.length] ?? -1; +} + +/* + * 超大标题。一个字被丢掉,只剩轮廓(dropout); + * 除第一行外,后面的行整体缩进,像被推开了半格。 + * 读屏和测试读到的仍是完整的一句话。 + */ +export function DisplayTitle({ + as: Tag = "h1", + text, + drop = true, + stagger = true, + className, + ...props +}: React.HTMLAttributes & { + ref?: React.Ref; + as?: "h1" | "h2" | "h3" | "p"; + text: string; + drop?: boolean; + stagger?: boolean; +}) { + const glyphs = Array.from(text); + const dropped = drop ? droppedGlyphIndex(text) : -1; + + return ( + + {dropped < 0 + ? text + : [ + glyphs.slice(0, dropped).join(""), + + {glyphs[dropped]} + , + glyphs.slice(dropped + 1).join(""), + ]} + + ); +} diff --git a/packages/ui/src/components/deco/dither-sky.tsx b/packages/ui/src/components/deco/dither-sky.tsx new file mode 100644 index 00000000..6edf5e86 --- /dev/null +++ b/packages/ui/src/components/deco/dither-sky.tsx @@ -0,0 +1,168 @@ +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; + +/* + * 抖色天空:一块静态的像素云,按窗口大小画一次,主题变了再画一次。 + * 有序抖色(4×4 Bayer)叠一点固定种子的颗粒;越往下越稀,最后散成纸面。 + * 不做动画——上一版的粒子太闹,这里只要一块安静的色场。 + */ + +const PIXEL = 2; + +const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map( + (value) => (value + 0.5) / 16, +); + +type Rgb = [number, number, number]; + +const PALETTES: Record<"light" | "dark", { sky: Rgb; cloud: Rgb; haze: Rgb }> = + { + light: { + sky: [164, 196, 236], + cloud: [245, 160, 182], + haze: [212, 188, 240], + }, + dark: { + sky: [36, 46, 66], + cloud: [96, 50, 74], + haze: [60, 52, 90], + }, + }; + +function hash2(x: number, y: number, seed: number) { + let h = (x * 374761393 + y * 668265263 + seed * 2147483647) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967295; +} + +function valueNoise(x: number, y: number, seed: number) { + const xi = Math.floor(x); + const yi = Math.floor(y); + const xf = x - xi; + const yf = y - yi; + const u = xf * xf * (3 - 2 * xf); + const v = yf * yf * (3 - 2 * yf); + const a = hash2(xi, yi, seed); + const b = hash2(xi + 1, yi, seed); + const c = hash2(xi, yi + 1, seed); + const d = hash2(xi + 1, yi + 1, seed); + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; +} + +function fbm(x: number, y: number, seed: number) { + let sum = 0; + let amp = 0.5; + let freq = 1; + for (let octave = 0; octave < 5; octave += 1) { + sum += amp * valueNoise(x * freq, y * freq, seed + octave * 17); + freq *= 2.03; + amp *= 0.5; + } + return sum; +} + +function smoothstep(edge0: number, edge1: number, x: number) { + const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); +} + +function paint(canvas: HTMLCanvasElement, seed: number, dark: boolean) { + const rect = canvas.getBoundingClientRect(); + const width = Math.max(1, Math.ceil(rect.width / PIXEL)); + const height = Math.max(1, Math.ceil(rect.height / PIXEL)); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return; + + const palette = PALETTES[dark ? "dark" : "light"]; + const image = context.createImageData(width, height); + const data = image.data; + const scale = 1 / 60; + + for (let y = 0; y < height; y += 1) { + const v = y / height; + for (let x = 0; x < width; x += 1) { + const threshold = + BAYER[(y % 4) * 4 + (x % 4)] * 0.72 + hash2(x, y, seed + 99) * 0.28; + + // 云:上半部多、往两侧堆积,中间留出一块天 + const n = fbm(x * scale, y * scale * 1.5, seed); + const side = Math.abs(x / width - 0.48) * 0.32; + const lift = 0.08 - v * 0.26 + side; + const cloud = smoothstep(0.44, 0.7, n + lift); + const haze = 1 - Math.abs(cloud - 0.5) * 2; + + // 往下散成纸面;云心里也透出一点纸色,像印得不实 + const fade = smoothstep(0.18, 0.92, v + (n - 0.5) * 0.45); + if (fade > threshold) continue; + if (cloud > 0.9 && hash2(x, y, seed + 3) < 0.22) continue; + + let color = palette.sky; + if (cloud > threshold) color = palette.cloud; + if (haze > 0.5 && hash2(x, y, seed + 7) < haze * 0.55) { + color = palette.haze; + } + + const offset = (y * width + x) * 4; + data[offset] = color[0]; + data[offset + 1] = color[1]; + data[offset + 2] = color[2]; + data[offset + 3] = 255; + } + } + + context.putImageData(image, 0, 0); +} + +export function DitherSky({ + seed = 11, + className, +}: { + seed?: number; + className?: string; +}) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + let frame = 0; + const isDark = () => document.documentElement.classList.contains("dark"); + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => paint(canvas, seed, isDark())); + }; + + schedule(); + + const resize = + "ResizeObserver" in window ? new ResizeObserver(schedule) : null; + resize?.observe(canvas); + + const themeWatcher = new MutationObserver(schedule); + themeWatcher.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => { + cancelAnimationFrame(frame); + resize?.disconnect(); + themeWatcher.disconnect(); + }; + }, [seed]); + + return ( + + ); +} diff --git a/packages/ui/src/components/deco/marks.tsx b/packages/ui/src/components/deco/marks.tsx new file mode 100644 index 00000000..0716c171 --- /dev/null +++ b/packages/ui/src/components/deco/marks.tsx @@ -0,0 +1,326 @@ +import type * as React from "react"; +import { useEffect, useState } from "react"; +import { CIPHER_VERTICAL, chunk } from "@/lib/eggs"; +import { cn } from "@/lib/utils"; + +/** 分节记号:∵ x / x ∵ */ +export function SectionMark({ + side = "left", + className, +}: { + side?: "left" | "right"; + className?: string; +}) { + return ( + + ); +} + +/** 套准记号 ⊕ */ +export function RegMark({ + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +}) { + return ( + + ); +} + +/** 标签 ……… 数值 */ +export function Leader({ + label, + value, + className, + valueClassName, +}: { + label: React.ReactNode; + value: React.ReactNode; + className?: string; + valueClassName?: string; +}) { + return ( +
+ {label} +
+ ); +} + +/** 分段条:十格,填到哪算哪,最后一格按比例填一部分 */ +export function SegBar({ + value, + segments = 10, + className, + fillClassName = "bg-ink", +}: { + value: number; + segments?: number; + className?: string; + fillClassName?: string; +}) { + const clamped = Math.max(0, Math.min(1, value)); + return ( + + ); +} + +/** 尺寸标注线:量出目标元素的宽度,画在它上方 */ +export function DimensionLine({ + target, + className, + prefix = "W", +}: { + target: React.RefObject; + className?: string; + prefix?: string; +}) { + const [width, setWidth] = useState(null); + + useEffect(() => { + const node = target.current; + if (!node) return; + const update = () => setWidth(node.offsetWidth); + update(); + if (!("ResizeObserver" in window)) return; + const observer = new ResizeObserver(update); + observer.observe(node); + return () => observer.disconnect(); + }, [target]); + + return ( + + ); +} + +/** 彩蛋的复制:文字不可选中,点一下才复制,原处闪一下 */ +function useCopyFlash(text: string) { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = window.setTimeout(() => setCopied(false), 1200); + return () => window.clearTimeout(timer); + }, [copied]); + const copy = () => { + void navigator.clipboard + ?.writeText(text) + .then(() => setCopied(true)) + .catch(() => undefined); + }; + return { copied, copy }; +} + +/** 可以点一下复制的一串字(只给鼠标用,不进 Tab 顺序,读屏跳过) */ +export function CopyText({ + text, + className, + copiedClassName = "bg-ink text-paper", + children, +}: { + text: string; + className?: string; + copiedClassName?: string; + children?: React.ReactNode; +}) { + const { copied, copy } = useCopyFlash(text); + return ( + + ); +} + +/** 竖排的一串 base64(彩蛋)。点一下复制,解六次。 */ +export function VerticalCipher({ className }: { className?: string }) { + return ( + + ); +} + +/** [B.64] 块(彩蛋)。点一下复制。 */ +export function B64Block({ + data, + width = 30, + label = "[B.64]", + className, +}: { + data: string; + width?: number; + label?: string; + className?: string; +}) { + const { copied, copy } = useCopyFlash(data); + return ( + + ); +} + +/* + * 引线标注:从标题里被丢掉的那个字拉出一条折线,写上 DROPPED。 + * 标题的父元素要是 relative,标注按它定位。 + */ +export function GlyphCallout({ + target, + label, + className, +}: { + target: React.RefObject; + label: string; + className?: string; +}) { + const [point, setPoint] = useState<{ x: number; y: number } | null>(null); + + useEffect(() => { + const node = target.current; + const container = node?.parentElement; + if (!node || !container) return; + + const update = () => { + const glyph = node.querySelector("[data-dropped]"); + if (!glyph) { + setPoint(null); + return; + } + const g = glyph.getBoundingClientRect(); + const c = container.getBoundingClientRect(); + setPoint({ + x: Math.round(g.left - c.left + g.width * 0.7), + y: Math.round(g.top - c.top + g.height * 0.22), + }); + }; + + update(); + void document.fonts?.ready.then(update); + const resize = + "ResizeObserver" in window ? new ResizeObserver(update) : null; + resize?.observe(node); + const mutation = new MutationObserver(update); + mutation.observe(node, { + childList: true, + subtree: true, + characterData: true, + }); + return () => { + resize?.disconnect(); + mutation.disconnect(); + }; + }, [target]); + + if (!point) return null; + + return ( + + ); +} diff --git a/packages/ui/src/components/deco/page-header.tsx b/packages/ui/src/components/deco/page-header.tsx new file mode 100644 index 00000000..5e71f026 --- /dev/null +++ b/packages/ui/src/components/deco/page-header.tsx @@ -0,0 +1,69 @@ +import { ArrowLeftIcon } from "lucide-react"; +import type * as React from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +/* + * 页头:白标签 + 超大标题,背后压一个空心的章节号。 + * 二级页面左边多一个返回按钮。 + */ +export function PageHeader({ + index, + eyebrow, + title, + onBack, + backLabel, + actions, + className, +}: { + index?: string; + eyebrow?: React.ReactNode; + title: React.ReactNode; + onBack?: () => void; + backLabel?: string; + actions?: React.ReactNode; + className?: string; +}) { + return ( +
+ {index && ( + + )} +
+ {onBack && ( + + )} +
+ {eyebrow &&

{eyebrow}

} +

+ {title} +

+
+
+ {actions && ( +
+ {actions} +
+ )} +
+ ); +} diff --git a/packages/ui/src/components/deco/widgets.tsx b/packages/ui/src/components/deco/widgets.tsx new file mode 100644 index 00000000..45d5124f --- /dev/null +++ b/packages/ui/src/components/deco/widgets.tsx @@ -0,0 +1,165 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { isLauncherFixtureMode } from "@/lib/launcher-runtime"; +import { cn } from "@/lib/utils"; +import { Win } from "./win"; + +function prefersReducedMotion() { + return ( + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +function mulberry32(seed: number) { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const RATES = [0.1, 0.25, 0.5, 0.75, 0.9] as const; + +/* + * DROPOUT 小部件:一排神经元,每隔一会儿按 p 随机丢掉一部分(只剩虚线框)。 + * 点一下换一个 p。这是 DropOut 名字的来历:训练时随机丢掉神经元,剩下的更强。 + */ +export function DropoutWidget({ + columns = 8, + rows = 4, + className, +}: { + columns?: number; + rows?: number; + className?: string; +}) { + const { t } = useTranslation(); + const frozen = isLauncherFixtureMode() || prefersReducedMotion(); + const [rateIndex, setRateIndex] = useState(2); + const [tick, setTick] = useState(0); + const rate = RATES[rateIndex] ?? 0.5; + const total = columns * rows; + + useEffect(() => { + if (frozen) return; + const timer = window.setInterval(() => setTick((value) => value + 1), 1400); + return () => window.clearInterval(timer); + }, [frozen]); + + const mask = useMemo(() => { + const random = mulberry32(0x5eed + tick * 7919 + rateIndex * 31); + return Array.from({ length: total }, () => random() >= rate); + }, [rate, rateIndex, tick, total]); + + const kept = mask.filter(Boolean).length; + const percent = Math.round(rate * 100); + + return ( + + + + ); +} + +/* 夹具模式下固定在这一刻,截图才能逐像素一致 */ +const FIXTURE_NOW = Date.UTC(2026, 9, 24, 4, 30, 0); +const MINECRAFT_RELEASE = Date.UTC(2011, 10, 18); + +function minecraftClock(now: number) { + const ticks = Math.floor(now / 50) % 24000; + const day = Math.floor((now - MINECRAFT_RELEASE) / 1_200_000); + // 第 0 刻是早上六点 + const minutesOfDay = ((ticks / 1000 + 6) % 24) * 60; + const hours = Math.floor(minutesOfDay / 60); + const minutes = Math.floor(minutesOfDay % 60); + const daytime = ticks < 12542 || ticks > 23460; + return { ticks, day, hours, minutes, daytime }; +} + +/* + * DAYLIGHT 小部件:把现实时间换算成游戏里的时间(一天二十分钟), + * 第几天从 Minecraft 正式版发布那天算起。 + */ +export function DaylightWidget({ className }: { className?: string }) { + const { t, i18n } = useTranslation(); + const frozen = isLauncherFixtureMode(); + const [now, setNow] = useState(() => (frozen ? FIXTURE_NOW : Date.now())); + + useEffect(() => { + if (frozen) return; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [frozen]); + + const clock = minecraftClock(now); + const time = `${String(clock.hours).padStart(2, "0")}:${String(clock.minutes).padStart(2, "0")}`; + const day = new Intl.NumberFormat(i18n.resolvedLanguage ?? "en").format( + clock.day, + ); + + return ( + +
+

+ {t("deco.day", { day })} +

+

+ {t("deco.gameTime", { time })} + +

+