You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/05 15:05:22 UTC

(maka) 01/01: fix(desktop): make the About update row act on its state and align its support buttons

This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch fix/about-page-update-action-and-layout
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 5e3552e74542dd060a7ce4b01b8cbdc2eba29cee
Author: AstroHan <le...@outlook.com>
AuthorDate: Sat Sep 5 23:05:15 2026 +0800

    fix(desktop): make the About update row act on its state and align its support buttons
    
    The About page rendered an enabled 检查更新 through every updater state, but
    the service returns the current status untouched while an update is
    downloading, verifying, downloaded or installing — so on a nightly, whose
    steady state is downloaded, the button did nothing when pressed. The row now
    carries the one control its state can honour (check / restart / retry, or an
    inline spinner while the updater works), and restart and retry go through the
    sidebar footer's own handshake, which now lives once in useAppUpdateReminder
    instead of being spelled out in app-shell.
    
    The three support rows end in the same ghost button so their faces share one
    right edge; the keycaps sit before the copy button. The lead is the wordmark
    in brand colour, and a trailing 开源 group names the ASF project and links to
    source, release notes and license, so the page no longer ends two-thirds down
    the pane.
    
    The wordmark's viewBox is cropped to its ink: the trace carries 27 units of
    bearing on the left and 21 below, which a left-aligned mark showed as a 7.5px
    indent. The two centered heroes take compensating widths so their ink renders
    at the size it did.
    
    Generated-by: Claude Code
---
 apps/desktop/e2e/about-page.spec.ts                |   7 +-
 .../src/main/__tests__/about-update-status.test.ts |  99 +++++++++---
 apps/desktop/src/renderer/app-shell-app-update.ts  |  70 +++++++-
 apps/desktop/src/renderer/app-shell.tsx            |  67 +-------
 .../renderer/locales/settings-preferences-copy.ts  | 108 ++++++++-----
 apps/desktop/src/renderer/onboarding-hero.tsx      |   2 +-
 .../src/renderer/settings/about-settings-page.tsx  | 180 ++++++++++++---------
 .../src/renderer/settings/about-update-status.ts   |  96 ++++++++---
 .../stories/settings/settings-pages.stories.tsx    |  20 +++
 packages/ui/src/chat-empty-hero.tsx                |   4 +-
 packages/ui/src/maka-wordmark.tsx                  |   7 +-
 11 files changed, 431 insertions(+), 229 deletions(-)

diff --git a/apps/desktop/e2e/about-page.spec.ts b/apps/desktop/e2e/about-page.spec.ts
index ad34ab3b23..4c7ea5b407 100644
--- a/apps/desktop/e2e/about-page.spec.ts
+++ b/apps/desktop/e2e/about-page.spec.ts
@@ -29,8 +29,9 @@ test('About renders channel facts, support, and privacy on a dev checkout', asyn
   await expect(page.getByText('本地开发版', { exact: true })).toBeVisible();
   await expect(page.getByText('本地开发构建,不检查更新。')).toBeVisible();
 
-  // A dev checkout follows no feed, so the whole update row is absent — it is
+  // A dev checkout follows no feed, so the whole update group is absent — it is
   // not a disabled button next to a sentence repeating the line above it.
+  await expect(page.getByRole('heading', { name: '软件更新', exact: true })).toHaveCount(0);
   await expect(page.getByRole('button', { name: '检查更新' })).toHaveCount(0);
 
   // Support lives outside the info conditional: usable even when `app.info`
@@ -44,4 +45,8 @@ test('About renders channel facts, support, and privacy on a dev checkout', asyn
   // Three commitments, not the old wall of five bullets.
   const privacyList = page.getByRole('list', { name: '隐私承诺' });
   await expect(privacyList.getByRole('listitem')).toHaveCount(3);
+
+  // The project links are static content and render whatever `app.info` did.
+  await expect(page.getByRole('heading', { name: '开源', exact: true })).toBeVisible();
+  await expect(page.getByRole('link', { name: /源码/ })).toBeVisible();
 });
diff --git a/apps/desktop/src/main/__tests__/about-update-status.test.ts b/apps/desktop/src/main/__tests__/about-update-status.test.ts
index a3e7e70ac2..038c43c282 100644
--- a/apps/desktop/src/main/__tests__/about-update-status.test.ts
+++ b/apps/desktop/src/main/__tests__/about-update-status.test.ts
@@ -21,7 +21,7 @@ import assert from 'node:assert/strict';
 import { test } from 'node:test';
 import {
   aboutChannelFacts,
-  aboutUpdateStatusDetail,
+  aboutUpdateRow,
 } from '../../renderer/settings/about-update-status.js';
 import { getSettingsPreferencesCopy } from '../../renderer/locales/settings-preferences-copy.js';
 
@@ -45,33 +45,86 @@ test('buildMode decides before updateChannel, whose dev value is a placeholder',
   assert.equal(facts.summary, '本地开发构建,不检查更新。');
 });
 
-test('the nightly steady states each read as themselves', () => {
-  const detail = (status: Parameters<typeof aboutUpdateStatusDetail>[0]) =>
-    aboutUpdateStatusDetail(status, copy, { isDevBuild: false });
+const current = '0.2.0-dev.11.20260831';
+const latest = '0.2.0-dev.12.20260901';
 
+test('the row only offers a check where the service would honour one', () => {
+  assert.equal(aboutUpdateRow(null, copy).action, 'check');
+  assert.equal(aboutUpdateRow({ state: 'idle', currentVersion: current }, copy).action, 'check');
   assert.equal(
-    detail({
-      state: 'downloading',
-      currentVersion: '0.2.0-dev.11.20260831',
-      latestVersion: '0.2.0-dev.12.20260901',
-      progress: { percent: 42.4, bytesPerSecond: 1, transferred: 1, total: 2 },
-    }),
-    '正在下载 v0.2.0-dev.12.20260901(42%)…',
+    aboutUpdateRow({ state: 'not-available', currentVersion: current }, copy).action,
+    'check',
+  );
+  assert.equal(
+    aboutUpdateRow({ state: 'checking', currentVersion: current }, copy).action,
+    'checking',
   );
+  // The service returns the current status untouched for these three, so a
+  // check button here was a control that did nothing when pressed.
   assert.equal(
-    detail({
-      state: 'verifying',
-      currentVersion: '0.2.0-dev.11.20260831',
-      latestVersion: '0.2.0-dev.12.20260901',
-    }),
-    '正在验证 v0.2.0-dev.12.20260901 的发布来源…',
+    aboutUpdateRow({ state: 'available', currentVersion: current, latestVersion: latest }, copy).action,
+    'busy',
   );
   assert.equal(
-    detail({
-      state: 'downloaded',
-      currentVersion: '0.2.0-dev.11.20260831',
-      latestVersion: '0.2.0-dev.12.20260901',
-    }),
-    'v0.2.0-dev.12.20260901 已下载,可在侧栏选择重启安装。',
+    aboutUpdateRow({ state: 'verifying', currentVersion: current, latestVersion: latest }, copy).action,
+    'busy',
+  );
+  assert.equal(
+    aboutUpdateRow({ state: 'installing', currentVersion: current, latestVersion: latest }, copy).action,
+    'busy',
+  );
+});
+
+test('the nightly steady states each read as themselves', () => {
+  const downloading = aboutUpdateRow(
+    {
+      state: 'downloading',
+      currentVersion: current,
+      latestVersion: latest,
+      progress: { percent: 42.4, bytesPerSecond: 1, transferred: 1, total: 2 },
+    },
+    copy,
+  );
+  assert.deepEqual(downloading, {
+    label: '正在下载 v0.2.0-dev.12.20260901(42%)',
+    description: '下载完成后可在这里重启安装。',
+    action: 'busy',
+  });
+
+  const downloaded = aboutUpdateRow(
+    { state: 'downloaded', currentVersion: current, latestVersion: latest },
+    copy,
+  );
+  assert.deepEqual(downloaded, {
+    label: 'v0.2.0-dev.12.20260901 已下载',
+    description: '重启后完成安装;仍有任务在运行时会先询问。',
+    action: 'install',
+  });
+});
+
+test('a failure retries only when it knows which update failed', () => {
+  const download = aboutUpdateRow(
+    {
+      state: 'error',
+      currentVersion: current,
+      latestVersion: latest,
+      operation: 'download',
+      message: 'ECONNRESET',
+    },
+    copy,
+    { errorDetail: (message) => `网络错误:${message}` },
+  );
+  assert.deepEqual(download, {
+    label: '下载更新失败',
+    description: '网络错误:ECONNRESET',
+    action: 'retry',
+  });
+
+  const check = aboutUpdateRow(
+    { state: 'error', currentVersion: current, operation: 'check', message: 'offline' },
+    copy,
   );
+  assert.equal(check.label, '检查更新失败');
+  assert.equal(check.description, 'offline');
+  assert.equal(check.action, 'check');
 });
diff --git a/apps/desktop/src/renderer/app-shell-app-update.ts b/apps/desktop/src/renderer/app-shell-app-update.ts
index dd57d850e3..9a7609d33c 100644
--- a/apps/desktop/src/renderer/app-shell-app-update.ts
+++ b/apps/desktop/src/renderer/app-shell-app-update.ts
@@ -17,8 +17,12 @@
  * under the License.
  */
 
-import type { SidebarUpdateReminder } from '@maka/ui';
+import { useCallback, useMemo, useRef } from 'react';
+import type { UiLocale } from '@maka/core/ui-locale';
+import { useToast, type SidebarUpdateReminder } from '@maka/ui';
 import type { AppUpdateStatus } from '../preload/bridge-contract.js';
+import { requestDownloadedAppUpdate } from './app-update-install.js';
+import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js';
 
 /**
  * The single answer to "which update states need the user".
@@ -49,3 +53,67 @@ export function updateReminderFromStatus(
   }
   return undefined;
 }
+
+/**
+ * The reminder plus the one thing it lets the user do: restart into a
+ * downloaded update, or retry a failed one. The sidebar footer and the About
+ * page both offer that action, so the install handshake (active-task
+ * confirmation, failure toasts, the in-flight latch) lives here once.
+ */
+export function useAppUpdateReminder(
+  status: AppUpdateStatus | null,
+  locale: UiLocale,
+): { reminder: SidebarUpdateReminder | undefined; openUpdate: () => void } {
+  const toast = useToast();
+  const installInFlightRef = useRef(false);
+  const reminder = useMemo(() => updateReminderFromStatus(status), [status]);
+  const reminderState = reminder?.state;
+
+  const openUpdate = useCallback(() => {
+    const copy = getShellCopy(locale).app;
+    if (reminderState === 'downloaded') {
+      if (installInFlightRef.current) return;
+      installInFlightRef.current = true;
+      void requestDownloadedAppUpdate({
+        installUpdate: (input) => window.maka.app.installUpdate(input),
+        confirmActiveTasks: () => toast.confirm({
+          title: copy.updateActiveTasksTitle,
+          description: copy.updateActiveTasksDescription,
+          confirmLabel: copy.updateActiveTasksConfirm,
+          cancelLabel: copy.updateActiveTasksCancel,
+          destructive: true,
+        }),
+      })
+        .then((outcome) => {
+          if (outcome.kind !== 'failed') return;
+          if (outcome.reason === 'install_failed') return;
+          toast.error(copy.updateInstallFailedTitle, copy.updateInstallManualFallback);
+        })
+        .catch((error) => {
+          toast.error(
+            copy.updateInstallFailedTitle,
+            localizedShellErrorMessage(error, copy.updateInstallFailedFallback, locale),
+          );
+        })
+        .finally(() => {
+          installInFlightRef.current = false;
+        });
+      return;
+    }
+    if (reminderState !== 'error') return;
+    void window.maka.app
+      .retryUpdateDownload()
+      .then((next) => {
+        if (next.state !== 'error') return;
+        toast.error(copy.updateRetryFailedTitle, copy.updateRetryFailedFallback);
+      })
+      .catch((error) => {
+        toast.error(
+          copy.updateRetryFailedTitle,
+          localizedShellErrorMessage(error, copy.updateRetryFailedFallback, locale),
+        );
+      });
+  }, [reminderState, toast, locale]);
+
+  return { reminder, openUpdate };
+}
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index f6473dd7e7..3f4f0ce0bd 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -126,10 +126,7 @@ import type {
   OnboardingSnapshot,
 } from '../preload/bridge-contract.js';
 import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../preload/transcript-contract.js';
-import {
-  isAppUpdateInstallFailure,
-  requestDownloadedAppUpdate,
-} from './app-update-install';
+import { isAppUpdateInstallFailure } from './app-update-install';
 import { ProviderLogo } from './settings/provider-display';
 import { ProviderBrandMark } from './settings/provider-brand-marks';
 import { RuntimeHostSshTerminalDialog } from './settings/runtime-host-ssh-terminal-dialog.js';
@@ -165,7 +162,7 @@ import {
   presentContextCompactionResult,
 } from './app-shell-context-compaction';
 import { AppShellTopbarActions } from './app-shell-chrome-actions';
-import { updateReminderFromStatus } from './app-shell-app-update';
+import { useAppUpdateReminder } from './app-shell-app-update';
 import { AppShellDetailPanel } from './app-shell-detail-panel';
 import { AppShellOverlays } from './app-shell-overlays';
 import type { ArchivedTasksBridge } from './settings/tasks-settings-page';
@@ -328,7 +325,6 @@ function AppShellContent({
   const toastApi = useToast();
   const [appUpdateStatus, setAppUpdateStatus] = useState<AppUpdateStatus | null>(null);
   const sharedSessionDialog = useSessionCollaborationDialog();
-  const updateInstallInFlightRef = useRef(false);
   const notifiedInstallErrorRef = useRef<string | null>(null);
   const previousInterruptionShownRef = useRef(false);
   const {
@@ -715,61 +711,10 @@ function AppShellContent({
     };
   }, []);
 
-  const updateReminder = updateReminderFromStatus(appUpdateStatus);
-  // Dispatches on the task, not on the raw status: the footer is this
-  // callback's only caller and it only renders for the two states above, so
-  // reading the status again here would be the same "who needs the user" list
-  // maintained twice.
-  const openUpdateDownload = useCallback(() => {
-    if (updateReminder?.state === 'downloaded') {
-      if (updateInstallInFlightRef.current) return;
-      updateInstallInFlightRef.current = true;
-      void requestDownloadedAppUpdate({
-        installUpdate: (input) => window.maka.app.installUpdate(input),
-        confirmActiveTasks: () => toastApi.confirm({
-          title: shellCopy.updateActiveTasksTitle,
-          description: shellCopy.updateActiveTasksDescription,
-          confirmLabel: shellCopy.updateActiveTasksConfirm,
-          cancelLabel: shellCopy.updateActiveTasksCancel,
-          destructive: true,
-        }),
-      })
-        .then((outcome) => {
-          if (outcome.kind !== 'failed') return;
-          if (outcome.reason === 'install_failed') return;
-          toastApi.error(
-            shellCopy.updateInstallFailedTitle,
-            shellCopy.updateInstallManualFallback,
-          );
-        })
-        .catch((error) => {
-          toastApi.error(
-            shellCopy.updateInstallFailedTitle,
-            localizedShellErrorMessage(error, shellCopy.updateInstallFailedFallback, uiLocale),
-          );
-        })
-        .finally(() => {
-          updateInstallInFlightRef.current = false;
-        });
-      return;
-    }
-    if (!updateReminder) return;
-    void window.maka.app
-      .retryUpdateDownload()
-      .then((next) => {
-        if (next.state !== 'error') return;
-        toastApi.error(
-          shellCopy.updateRetryFailedTitle,
-          shellCopy.updateRetryFailedFallback,
-        );
-      })
-      .catch((error) => {
-        toastApi.error(
-          shellCopy.updateRetryFailedTitle,
-          localizedShellErrorMessage(error, shellCopy.updateRetryFailedFallback, uiLocale),
-        );
-      });
-  }, [updateReminder, shellCopy, toastApi, uiLocale]);
+  const { reminder: updateReminder, openUpdate: openUpdateDownload } = useAppUpdateReminder(
+    appUpdateStatus,
+    uiLocale,
+  );
   // Persisted composer defaults seed the empty-state model, project path, and
   // recent workspace history so the home view is populated before the async
   // `app:info` round-trip completes on mount.
diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
index 343e1ba79e..8bbb93a646 100644
--- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
@@ -237,24 +237,34 @@ export type SettingsPreferencesCopy = {
     privacyLabel: string;
     privacyTitle: string;
     privacyPoints: readonly string[];
-    copying: string;
     copyDiagnostics: string;
     copyHelp: string;
     keyboardShortcuts: string;
     keyboardShortcutsHelp: string;
     keyboardShortcutsOpen: string;
     reportIssueLabel: string;
+    updatesTitle: string;
     checkForUpdates: string;
     checkingForUpdates: string;
+    restartToUpdate: string;
+    retryUpdate: string;
     updateIdle: string;
     updateNotAvailable: string;
+    /** The schedule the updater keeps on its own, so nobody has to press the button. */
+    updateAutoCheckHint: string;
     updateAvailable: (version: string) => string;
     updateDownloading: (version: string, percent: number) => string;
     updateVerifying: (version: string) => string;
+    updateDownloadHint: string;
     updateDownloaded: (version: string) => string;
+    updateDownloadedHint: string;
     updateInstalling: (version: string) => string;
-    updateCheckFailed: string;
-    updateCheckFailedDetail: (message: string) => string;
+    updateFailed: Record<'check' | 'download' | 'install', string>;
+    openSourceTitle: string;
+    openSourceSummary: string;
+    sourceCode: string;
+    releaseNotes: string;
+    license: string;
   };
   password: {
     copyFailed: string;
@@ -349,21 +359,29 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
         release: '正式发布版,自动接收稳定更新。',
       },
       supportTitle: '支持',
-      copying: '复制中…', copyDiagnostics: '复制诊断信息', copyAction: '复制', copyHelp: '复制版本、平台、隐藏主目录后的工作区路径与近期脱敏日志;仅写入剪贴板,不会自动上传。',
+      copyDiagnostics: '复制诊断信息', copyAction: '复制', copyHelp: '复制版本、平台、隐藏主目录后的工作区路径与近期脱敏日志;仅写入剪贴板,不会自动上传。',
       reportIssueLabel: '报告问题', reportIssueHelp: '带上诊断信息去 GitHub Issues,回复更快。', reportIssueOpen: '打开',
       keyboardShortcuts: '键盘快捷键', keyboardShortcutsHelp: 'Maka 支持的全部快捷键一览。', keyboardShortcutsOpen: '查看',
       privacyLabel: '隐私承诺', privacyTitle: '本地优先 · 隐私默认', privacyPoints: ['任务、设置、凭据和 Skill 指令文件都留在本机;模型密钥保存在本机凭据文件内,订阅令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权;每个任务都会在本机保留消息、工具调用、权限决策与模式变更记录。'],
+      updatesTitle: '软件更新',
       checkForUpdates: '检查更新',
-      checkingForUpdates: '检查中…',
-      updateIdle: '尚未检查更新。',
-      updateNotAvailable: '已是最新版本。',
-      updateAvailable: (version) => `发现新版本 v${version},正在准备下载…`,
-      updateDownloading: (version, percent) => `正在下载 v${version}(${percent}%)…`,
-      updateVerifying: (version) => `正在验证 v${version} 的发布来源…`,
-      updateDownloaded: (version) => `v${version} 已下载,可在侧栏选择重启安装。`,
-      updateInstalling: (version) => `正在安装 v${version}…`,
-      updateCheckFailed: '检查更新失败',
-      updateCheckFailedDetail: (message) => message,
+      checkingForUpdates: '正在检查更新…',
+      restartToUpdate: '重启并更新',
+      retryUpdate: '重试',
+      updateIdle: '尚未检查更新',
+      updateNotAvailable: '已是最新版本',
+      updateAutoCheckHint: '启动后、回到窗口时以及每 4 小时会自动检查一次。',
+      updateAvailable: (version) => `发现新版本 v${version}`,
+      updateDownloading: (version, percent) => `正在下载 v${version}(${percent}%)`,
+      updateVerifying: (version) => `正在验证 v${version} 的发布来源`,
+      updateDownloadHint: '下载完成后可在这里重启安装。',
+      updateDownloaded: (version) => `v${version} 已下载`,
+      updateDownloadedHint: '重启后完成安装;仍有任务在运行时会先询问。',
+      updateInstalling: (version) => `正在安装 v${version}`,
+      updateFailed: { check: '检查更新失败', download: '下载更新失败', install: '安装更新失败' },
+      openSourceTitle: '开源',
+      openSourceSummary: 'Apache Maka (incubating) 由 Apache 软件基金会孵化,以 Apache License 2.0 发布。',
+      sourceCode: '源码', releaseNotes: '发行说明', license: '许可证',
     },
     password: { copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', copying: '复制中', copied: '已复制', copy: '复制', hide: '隐藏', show: '显示', value: '凭据值' },
   },
@@ -440,21 +458,27 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
       passwordSavedPlaceholder: '密碼已儲存;輸入新密碼以替換',
     },
     about: {
-      loadFailed: '載入關於資訊失敗', loading: '正在載入關於頁', unavailable: '無法載入關於資訊', copied: '已複製診斷資訊', pasteHint: '檢查內容後,可直接貼上到問題報告', copyFailed: '複製失敗', clipboardUnavailable: '剪貼簿不可用或被系統拒絕。', devBuild: '本地開發版', nightlyBuild: 'Nightly', buildLabel: '建構', supportTitle: '支援', copyAction: '複製', reportIssueHelp: '帶上診斷資訊去 GitHub Issues,回覆更快。', reportIssueOpen: '開啟', channelSummaries: { dev: '本地開發建構,不檢查更新。', nightly: '每日建構的預發佈版,自動更新到最新 nightly,會覆蓋正式版安裝。', release: '正式發佈版,自動接收穩定更新。' }, privacyLabel: '隱私 [...]
+      loadFailed: '載入關於資訊失敗', loading: '正在載入關於頁', unavailable: '無法載入關於資訊', copied: '已複製診斷資訊', pasteHint: '檢查內容後,可直接貼上到問題報告', copyFailed: '複製失敗', clipboardUnavailable: '剪貼簿不可用或被系統拒絕。', devBuild: '本地開發版', nightlyBuild: 'Nightly', buildLabel: '建構', supportTitle: '支援', copyAction: '複製', reportIssueHelp: '帶上診斷資訊去 GitHub Issues,回覆更快。', reportIssueOpen: '開啟', channelSummaries: { dev: '本地開發建構,不檢查更新。', nightly: '每日建構的預發佈版,自動更新到最新 nightly,會覆蓋正式版安裝。', release: '正式發佈版,自動接收穩定更新。' }, privacyLabel: '隱私 [...]
 
+      updatesTitle: '軟體更新',
       checkForUpdates: '檢查更新',
-      checkingForUpdates: '檢查中…',
-
-
-      updateIdle: '尚未檢查更新。',
-      updateNotAvailable: '已是最新版本。',
-      updateAvailable: (version) => `發現新版本 v${version},正在準備下載…`,
-      updateDownloading: (version, percent) => `正在下載 v${version}(${percent}%)…`,
-      updateVerifying: (version) => `正在驗證 v${version} 的發佈來源…`,
-      updateDownloaded: (version) => `v${version} 已下載,可在側欄選擇重啟安裝。`,
-      updateInstalling: (version) => `正在安裝 v${version}…`,
-      updateCheckFailed: '檢查更新失敗',
-      updateCheckFailedDetail: (message) => message,
+      checkingForUpdates: '正在檢查更新…',
+      restartToUpdate: '重啟並更新',
+      retryUpdate: '重試',
+      updateIdle: '尚未檢查更新',
+      updateNotAvailable: '已是最新版本',
+      updateAutoCheckHint: '啟動後、回到視窗時以及每 4 小時會自動檢查一次。',
+      updateAvailable: (version) => `發現新版本 v${version}`,
+      updateDownloading: (version, percent) => `正在下載 v${version}(${percent}%)`,
+      updateVerifying: (version) => `正在驗證 v${version} 的發佈來源`,
+      updateDownloadHint: '下載完成後可在這裡重啟安裝。',
+      updateDownloaded: (version) => `v${version} 已下載`,
+      updateDownloadedHint: '重啟後完成安裝;仍有任務在執行時會先詢問。',
+      updateInstalling: (version) => `正在安裝 v${version}`,
+      updateFailed: { check: '檢查更新失敗', download: '下載更新失敗', install: '安裝更新失敗' },
+      openSourceTitle: '開源',
+      openSourceSummary: 'Apache Maka (incubating) 由 Apache 軟體基金會孵化,以 Apache License 2.0 發佈。',
+      sourceCode: '原始碼', releaseNotes: '發行說明', license: '授權條款',
     },
     password: { copyFailed: '複製失敗', clipboardUnavailable: '剪貼簿不可用或被系統拒絕。', copying: '複製中', copied: '已複製', copy: '複製', hide: '隱藏', show: '顯示', value: '憑據值' },
   },
@@ -501,21 +525,29 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
         release: 'The official release build. It receives stable updates automatically.',
       },
       supportTitle: 'Support',
-      copying: 'Copying…', copyDiagnostics: 'Copy diagnostics', copyAction: 'Copy', copyHelp: 'Copy version, platform, a home-redacted workspace path, and recent redacted logs. The report is written only to the clipboard and is never uploaded automatically.',
+      copyDiagnostics: 'Copy diagnostics', copyAction: 'Copy', copyHelp: 'Copy version, platform, a home-redacted workspace path, and recent redacted logs. The report is written only to the clipboard and is never uploaded automatically.',
       reportIssueLabel: 'Report an issue', reportIssueHelp: 'Open a GitHub issue with your diagnostics attached — replies come faster.', reportIssueOpen: 'Open',
       keyboardShortcuts: 'Keyboard shortcuts', keyboardShortcutsHelp: 'Every shortcut Maka responds to.', keyboardShortcutsOpen: 'View',
       privacyLabel: 'Privacy commitments', privacyTitle: 'Local first · Private by default', privacyPoints: ['Tasks, settings, credentials, and Skill instructions stay on this machine; model keys live in a local credential file and subscription tokens use secure system storage.', 'Maka sends no usage telemetry and contacts a model provider only when you enable it.', 'High-risk tool operations require explicit permission in the task; messages, tool calls, permission decisions, and mode ch [...]
+      updatesTitle: 'Software update',
       checkForUpdates: 'Check for updates',
-      checkingForUpdates: 'Checking…',
-      updateIdle: 'No update check has run yet.',
-      updateNotAvailable: 'You are on the latest version.',
-      updateAvailable: (version) => `Version v${version} is available and will download shortly…`,
-      updateDownloading: (version, percent) => `Downloading v${version} (${percent}%)…`,
-      updateVerifying: (version) => `Verifying the release provenance for v${version}…`,
-      updateDownloaded: (version) => `v${version} is ready. Restart from the sidebar to install.`,
-      updateInstalling: (version) => `Installing v${version}…`,
-      updateCheckFailed: 'Could not check for updates',
-      updateCheckFailedDetail: (message) => message,
+      checkingForUpdates: 'Checking for updates…',
+      restartToUpdate: 'Restart to update',
+      retryUpdate: 'Retry',
+      updateIdle: 'No update check has run yet',
+      updateNotAvailable: 'You are on the latest version',
+      updateAutoCheckHint: 'Maka checks on launch, when you return to the window, and every 4 hours.',
+      updateAvailable: (version) => `v${version} is available`,
+      updateDownloading: (version, percent) => `Downloading v${version} (${percent}%)`,
+      updateVerifying: (version) => `Verifying the release provenance for v${version}`,
+      updateDownloadHint: 'Once the download finishes, you can restart into it from here.',
+      updateDownloaded: (version) => `v${version} is ready to install`,
+      updateDownloadedHint: 'Restarting completes the install; running tasks are confirmed first.',
+      updateInstalling: (version) => `Installing v${version}`,
+      updateFailed: { check: 'Could not check for updates', download: 'Could not download the update', install: 'Could not install the update' },
+      openSourceTitle: 'Open source',
+      openSourceSummary: 'Apache Maka (incubating) is developed at the Apache Software Foundation and released under the Apache License 2.0.',
+      sourceCode: 'Source code', releaseNotes: 'Release notes', license: 'License',
     },
     password: { copyFailed: 'Copy failed', clipboardUnavailable: 'The clipboard is unavailable or access was denied.', copying: 'Copying', copied: 'Copied', copy: 'Copy', hide: 'Hide', show: 'Show', value: 'credential value' },
   },
diff --git a/apps/desktop/src/renderer/onboarding-hero.tsx b/apps/desktop/src/renderer/onboarding-hero.tsx
index f6780b6e3e..3670866b22 100644
--- a/apps/desktop/src/renderer/onboarding-hero.tsx
+++ b/apps/desktop/src/renderer/onboarding-hero.tsx
@@ -312,7 +312,7 @@ function OnboardingCard(props: {
   return (
     <Center width="100%" className="maka-onboarding-center">
       <VStack gap={4} hAlign="center" width="min(460px, 100%)" className="maka-onboarding">
-        <MakaWordmark width={112} className="maka-onboarding-wordmark" />
+        <MakaWordmark width={94} className="maka-onboarding-wordmark" />
         <Card
           width="100%"
           padding={6}
diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx
index ad7aa8ed1d..4c5ab9ffe7 100644
--- a/apps/desktop/src/renderer/settings/about-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx
@@ -18,25 +18,17 @@
  */
 
 import { useEffect, useState, type ReactNode } from 'react';
-import {
-  Heading,
-  HStack,
-  Link,
-  List,
-  ListItem,
-  Text,
-  Token,
-  VStack,
-} from '@astryxdesign/core';
+import { HStack, Link, List, ListItem, Spinner, Text, Token, VStack } from '@astryxdesign/core';
 import { Kbd } from '@astryxdesign/core/Kbd';
-import { Banner, Button, useMountedRef, useToast, useUiLocale } from '@maka/ui';
+import { Banner, Button, MakaWordmark, useMountedRef, useToast, useUiLocale } from '@maka/ui';
 import type { AppUpdateStatus } from '../../preload/bridge-contract.js';
 import { SettingsPage, SettingsRow, SettingsSection } from './settings-section.js';
 import { settingsActionErrorMessage } from './settings-error-copy.js';
 import { SettingsSkeletonStack } from './settings-skeleton.js';
 import { useActionGuard } from './use-action-guard.js';
-import { aboutChannelFacts, aboutUpdateStatusDetail } from './about-update-status.js';
+import { aboutChannelFacts, aboutUpdateRow } from './about-update-status.js';
 import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy.js';
+import { useAppUpdateReminder } from '../app-shell-app-update.js';
 import {
   defaultRuntimeHostDiagnosticTarget,
   runOnDefaultRuntimeHost,
@@ -44,7 +36,10 @@ import {
 
 type AppInfo = Awaited<ReturnType<typeof window.maka.app.info>>;
 
-const ISSUE_TRACKER_URL = 'https://github.com/apache/maka/issues';
+const REPOSITORY_URL = 'https://github.com/apache/maka';
+const ISSUE_TRACKER_URL = `${REPOSITORY_URL}/issues`;
+const RELEASES_URL = `${REPOSITORY_URL}/releases`;
+const LICENSE_URL = 'https://www.apache.org/licenses/LICENSE-2.0';
 
 export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
   const locale = useUiLocale();
@@ -58,6 +53,9 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
   const checkUpdateGuard = useActionGuard<'check'>();
   const aboutPageMountedRef = useMountedRef();
   const toast = useToast();
+  // Restart-into-update and retry go through the same handshake the sidebar
+  // footer uses (active-task confirmation, failure toasts), not a second one.
+  const { openUpdate } = useAppUpdateReminder(updateStatus, locale);
 
   useEffect(() => {
     let cancelled = false;
@@ -126,13 +124,13 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
       if (aboutPageMountedRef.current) setUpdateStatus(status);
       if (status.state === 'error') {
         toast.error(
-          copy.updateCheckFailed,
-          copy.updateCheckFailedDetail(settingsActionErrorMessage(status.message, locale)),
+          copy.updateFailed[status.operation],
+          settingsActionErrorMessage(status.message, locale),
         );
       }
     } catch (error) {
       if (aboutPageMountedRef.current) {
-        toast.error(copy.updateCheckFailed, settingsActionErrorMessage(error, locale));
+        toast.error(copy.updateFailed.check, settingsActionErrorMessage(error, locale));
       }
     } finally {
       checkUpdateGuard.finish();
@@ -141,6 +139,7 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
   }
 
   let identity: ReactNode;
+  let updates: ReactNode = null;
   if (!info && !infoError) {
     identity = (
       <SettingsSection variant="bare">
@@ -162,74 +161,88 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
     );
   } else {
     const channel = aboutChannelFacts(info, copy);
-    const isDevBuild = info.buildMode === 'dev';
     identity = (
-      /* The whole identity of this install, in one unlabeled lead group: what
-         it is, which channel it follows, what that means, and whether it is up
-         to date. Unlabeled because the page title already says 关于.
-
-         There used to be a 版本信息 readout under this (channel / version /
-         build / runtime / workspace) and a titled 软件更新 group around the
-         status line. Both were restatements: the first three rows repeat these
-         two lines verbatim, and runtime + workspace are already in the
-         diagnostic report and on the 数据 page. */
+      /* The identity of this install in one unlabeled lead group: the mark,
+         which channel it follows and what that means. Unlabeled because the
+         page title already says 关于. The wordmark is the same asset the
+         onboarding hero and the dock icon are traced from, so the page reads
+         as the product rather than as a text row about it. */
       <SettingsSection variant="bare">
-        <VStack gap={4}>
-          <VStack gap={2}>
-            <HStack gap={2} vAlign="center">
-              <Heading level={2}>Maka</Heading>
-              {/* Release installs carry no token: they are the default state,
-                  and Astryx keeps colour for what departs from it. */}
-              {channel.token ? (
-                <Token size="sm" label={channel.token.label} color={channel.token.color} />
-              ) : null}
-            </HStack>
-            <Text type="supporting" color="secondary">
-              {info.buildCommit
-                ? `v${info.appVersion} · ${copy.buildLabel} ${info.buildCommit}`
-                : `v${info.appVersion}`}
-            </Text>
-            <Text type="body">{channel.summary}</Text>
-          </VStack>
-          {/* A dev checkout follows no feed, so it gets no status line at all:
-              its channel sentence above already says it does not update, and
-              the updater's own dev copy said the same thing a second time in
-              the next paragraph. The row exists only where it can act.
-
-              The status line says what the button would tell you, so it carries
-              no label of its own. It wraps rather than crushes: at the 480px
-              window floor the sentence needs the full width. */}
-          {isDevBuild ? null : (
-            <HStack gap={3} justify="between" wrap="wrap">
-              <Text type="body">{aboutUpdateStatusDetail(updateStatus, copy, {
-                isDevBuild,
-                errorDetail: (message) => settingsActionErrorMessage(message, locale),
-              })}</Text>
-              <Button
-                variant="secondary"
-                size="sm"
-                isDisabled={checkingUpdate}
-                onClick={() => void checkForUpdates()}
-                label={checkingUpdate || updateStatus?.state === 'checking'
-                  ? copy.checkingForUpdates
-                  : copy.checkForUpdates}
-              />
-            </HStack>
-          )}
+        <VStack gap={2}>
+          <HStack gap={3} vAlign="center">
+            {/* Brand colour, as on the onboarding and empty-chat heroes: the
+                mark identifies the product, it is not a heading in ink. */}
+            <MakaWordmark width={112} title="Maka" style={{ color: 'var(--maka-brand)' }} />
+            {/* Release installs carry no token: they are the default state,
+                and Astryx keeps colour for what departs from it. */}
+            {channel.token ? (
+              <Token size="sm" label={channel.token.label} color={channel.token.color} />
+            ) : null}
+          </HStack>
+          <Text type="supporting" color="secondary">
+            {info.buildCommit
+              ? `v${info.appVersion} · ${copy.buildLabel} ${info.buildCommit}`
+              : `v${info.appVersion}`}
+          </Text>
+          <Text type="body">{channel.summary}</Text>
         </VStack>
       </SettingsSection>
     );
+    /* A dev checkout follows no feed, so it gets no update group at all: its
+       channel sentence above already says it does not update. Everywhere else
+       the row carries the one control its state can honour — the service
+       refuses a check while an update is downloading or downloaded, so a
+       检查更新 button in those states was a control that did nothing. */
+    if (info.buildMode !== 'dev') {
+      const row = aboutUpdateRow(updateStatus, copy, {
+        errorDetail: (message) => settingsActionErrorMessage(message, locale),
+      });
+      let control: ReactNode;
+      if (row.action === 'busy') {
+        control = <Spinner size="sm" />;
+      } else if (row.action === 'install' || row.action === 'retry') {
+        control = (
+          <Button
+            variant="secondary"
+            size="sm"
+            onClick={openUpdate}
+            label={row.action === 'install' ? copy.restartToUpdate : copy.retryUpdate}
+          />
+        );
+      } else {
+        control = (
+          <Button
+            variant="secondary"
+            size="sm"
+            isLoading={row.action === 'checking' || checkingUpdate}
+            onClick={() => void checkForUpdates()}
+            label={copy.checkForUpdates}
+          />
+        );
+      }
+      updates = (
+        <SettingsSection title={copy.updatesTitle}>
+          <SettingsRow label={row.label} description={row.description ?? undefined} end={control} />
+        </SettingsSection>
+      );
+    }
   }
 
   return (
     <SettingsPage>
       {identity}
+      {updates}
       {/* Support lives OUTSIDE the info conditional on purpose: copying
           diagnostics must not depend on `app.info` succeeding — that is the
           very moment a user needs it. The keyboard sheet used to be reachable
           only from the titlebar's `…` drawer and two shortcuts, which made
           the panel listing the shortcuts openable only by shortcut; this is
-          the entry a mouse can find. */}
+          the entry a mouse can find.
+
+          Every row ends in the same ghost `sm` button so the three faces
+          share one right edge; the issue link is that same button with an
+          href. The shortcut keycaps sit before the copy button rather than
+          after it for the same reason. */}
       <SettingsSection title={copy.supportTitle}>
         <SettingsRow
           label={copy.copyDiagnostics}
@@ -238,31 +251,32 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
             /* The verb on the face ("复制") is not a name; the row's label is.
                `Item` puts the row label in a sibling element, so each control
                carries its own aria-label instead of borrowing one. */
-            <HStack gap={2} vAlign="center">
+            <>
+              <Kbd keys="mod+shift+d" />
               <Button
                 variant="ghost"
                 size="sm"
-                isDisabled={copyingDiagnostics}
+                isLoading={copyingDiagnostics}
                 onClick={() => void copyDiagnostics()}
                 aria-label={copy.copyDiagnostics}
-                label={copyingDiagnostics ? copy.copying : copy.copyAction}
+                label={copy.copyAction}
               />
-              <Kbd keys="mod+shift+d" />
-            </HStack>
+            </>
           )}
         />
         <SettingsRow
           label={copy.reportIssueLabel}
           description={copy.reportIssueHelp}
           end={(
-            <Link
+            <Button
+              variant="ghost"
+              size="sm"
               href={ISSUE_TRACKER_URL}
               target="_blank"
               rel="noreferrer noopener"
               aria-label={copy.reportIssueLabel}
-            >
-              {copy.reportIssueOpen}
-            </Link>
+              label={copy.reportIssueOpen}
+            />
           )}
         />
         {props.onOpenKeyboardHelp ? (
@@ -290,6 +304,16 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
           </List>
         </SettingsSection>
       ) : null}
+      <SettingsSection title={copy.openSourceTitle} variant="bare">
+        <VStack gap={2}>
+          <Text type="body">{copy.openSourceSummary}</Text>
+          <HStack gap={4} wrap="wrap">
+            <Link href={REPOSITORY_URL} isExternalLink>{copy.sourceCode}</Link>
+            <Link href={RELEASES_URL} isExternalLink>{copy.releaseNotes}</Link>
+            <Link href={LICENSE_URL} isExternalLink>{copy.license}</Link>
+          </HStack>
+        </VStack>
+      </SettingsSection>
     </SettingsPage>
   );
 }
diff --git a/apps/desktop/src/renderer/settings/about-update-status.ts b/apps/desktop/src/renderer/settings/about-update-status.ts
index dd7c71e93a..a9fac40c6a 100644
--- a/apps/desktop/src/renderer/settings/about-update-status.ts
+++ b/apps/desktop/src/renderer/settings/about-update-status.ts
@@ -19,6 +19,7 @@
 
 import type { AppUpdateStatus, DesktopAppInfo } from '../../preload/bridge-contract.js';
 import type { SettingsPreferencesCopy } from '../locales/settings-preferences-copy.js';
+import { updateReminderFromStatus } from '../app-shell-app-update.js';
 
 type AboutCopy = SettingsPreferencesCopy['about'];
 
@@ -59,32 +60,83 @@ export function aboutChannelFacts(
   };
 }
 
+export interface AboutUpdateRow {
+  /** What the updater is doing or has found, as the row's label. */
+  readonly label: string;
+  /** What happens next, or the failure detail; null when the label says it all. */
+  readonly description: string | null;
+  /**
+   * The one control the row offers. `check` and `checking` are the same
+   * button, resting and busy; `install` restarts into a downloaded update and
+   * `retry` re-downloads a failed one (both go through the sidebar's own
+   * handshake); `busy` is the updater working on its own and offers nothing.
+   */
+  readonly action: 'check' | 'checking' | 'install' | 'retry' | 'busy';
+}
+
 /**
- * Map updater state to About-page detail copy (pure for unit tests).
+ * Map updater state to the About page's update row, pure for unit tests.
  *
- * There is no dev-build branch: a dev checkout renders no status line at all,
- * so the "development builds do not check GitHub releases" sentence this used
- * to return would only have restated the channel sentence above it.
+ * The action follows the state instead of always reading 检查更新: the service
+ * refuses a check while a download is in flight or an update sits downloaded
+ * (app-update-service.ts), so a check button in those states was a control that
+ * did nothing when pressed — which is exactly what a nightly user, whose steady
+ * state is `downloaded`, met every time.
  */
-export function aboutUpdateStatusDetail(
+export function aboutUpdateRow(
   status: AppUpdateStatus | null,
   copy: AboutCopy,
-  options: {
-    readonly isDevBuild: boolean;
-    readonly errorDetail?: (message: string) => string;
-  },
-): string {
-  if (!status || status.state === 'idle') return copy.updateIdle;
-  if (status.state === 'checking') return copy.checkingForUpdates;
-  if (status.state === 'not-available') return copy.updateNotAvailable;
-  if (status.state === 'available') return copy.updateAvailable(status.latestVersion);
-  if (status.state === 'downloading') {
-    return copy.updateDownloading(status.latestVersion, Math.round(status.progress.percent));
+  options: { readonly errorDetail?: (message: string) => string } = {},
+): AboutUpdateRow {
+  if (!status || status.state === 'idle') {
+    return { label: copy.updateIdle, description: copy.updateAutoCheckHint, action: 'check' };
+  }
+  switch (status.state) {
+    case 'checking':
+      return { label: copy.checkingForUpdates, description: null, action: 'checking' };
+    case 'not-available':
+      return {
+        label: copy.updateNotAvailable,
+        description: copy.updateAutoCheckHint,
+        action: 'check',
+      };
+    case 'available':
+      return {
+        label: copy.updateAvailable(status.latestVersion),
+        description: copy.updateDownloadHint,
+        action: 'busy',
+      };
+    case 'downloading':
+      return {
+        label: copy.updateDownloading(status.latestVersion, Math.round(status.progress.percent)),
+        description: copy.updateDownloadHint,
+        action: 'busy',
+      };
+    case 'verifying':
+      return {
+        label: copy.updateVerifying(status.latestVersion),
+        description: copy.updateDownloadHint,
+        action: 'busy',
+      };
+    case 'downloaded':
+      return {
+        label: copy.updateDownloaded(status.latestVersion),
+        description: copy.updateDownloadedHint,
+        action: 'install',
+      };
+    case 'installing':
+      return {
+        label: copy.updateInstalling(status.latestVersion),
+        description: null,
+        action: 'busy',
+      };
+    case 'error':
+      return {
+        label: copy.updateFailed[status.operation],
+        description: options.errorDetail ? options.errorDetail(status.message) : status.message,
+        // A failure that never learned of a release has nothing to re-download;
+        // the same rule the sidebar reminder applies.
+        action: updateReminderFromStatus(status) ? 'retry' : 'check',
+      };
   }
-  if (status.state === 'verifying') return copy.updateVerifying(status.latestVersion);
-  if (status.state === 'downloaded') return copy.updateDownloaded(status.latestVersion);
-  if (status.state === 'installing') return copy.updateInstalling(status.latestVersion);
-  return copy.updateCheckFailedDetail(
-    options.errorDetail ? options.errorDetail(status.message) : status.message,
-  );
 }
diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index f18c6c3fe8..670e2df4ca 100644
--- a/apps/desktop/stories/settings/settings-pages.stories.tsx
+++ b/apps/desktop/stories/settings/settings-pages.stories.tsx
@@ -2653,6 +2653,26 @@ export const AboutRelease: Story = {
   render: () => <SettingsStory section="about" />,
 };
 
+// Real path: a packaged install whose auto-download failed. The row names the
+// failed step and offers the retry the sidebar footer offers, instead of a
+// check button the service would ignore.
+export const AboutUpdateFailed: Story = {
+  decorators: [
+    withPackagedChannelBridge({
+      updateChannel: 'release',
+      appVersion: '0.2.0',
+      updateStatus: {
+        state: 'error',
+        currentVersion: '0.2.0',
+        latestVersion: '0.2.1',
+        operation: 'download',
+        message: 'net::ERR_CONNECTION_RESET',
+      },
+    }),
+  ],
+  render: () => <SettingsStory section="about" />,
+};
+
 // Real path: 设置 → 已归档任务, after archiving tasks from the rail's row menu.
 export const ArchivedTasks: Story = {
   decorators: [withSettingsBridge],
diff --git a/packages/ui/src/chat-empty-hero.tsx b/packages/ui/src/chat-empty-hero.tsx
index 30d503a7ad..f1e6beccf0 100644
--- a/packages/ui/src/chat-empty-hero.tsx
+++ b/packages/ui/src/chat-empty-hero.tsx
@@ -105,12 +105,12 @@ export function EmptyChatHero(props: {
   // users land, and they do not need the product explained every time.
   return (
     <section className="maka-hero maka-hero-empty-chat" aria-label={copy.ariaLabel}>
-      {/* 160px puts the mark's cap height at ~42px against the 25px
+      {/* 134px of ink puts the mark's cap height at ~42px against the 25px
           greeting — a 1.7:1 ratio that reads as anchor-then-line. At the
           104px it shipped with, the two were within 8% of each other and
           neither led. */}
       <div className="maka-hero-visual">
-        <MakaWordmark width={160} />
+        <MakaWordmark width={134} />
       </div>
       <header>
         <h1>
diff --git a/packages/ui/src/maka-wordmark.tsx b/packages/ui/src/maka-wordmark.tsx
index 69d1405e6e..9d98558278 100644
--- a/packages/ui/src/maka-wordmark.tsx
+++ b/packages/ui/src/maka-wordmark.tsx
@@ -36,7 +36,7 @@ import { MAKA_WORDMARK_PATH } from '@maka/core/maka-wordmark';
 import type { CSSProperties } from 'react';
 
 export interface MakaWordmarkProps {
-  /** Rendered width in px; height follows the 460:120 aspect ratio. */
+  /** Rendered width in px; height follows the 385:99 aspect ratio of the ink. */
   width?: number | string;
   className?: string;
   style?: CSSProperties;
@@ -50,8 +50,11 @@ export interface MakaWordmarkProps {
 
 export function MakaWordmark({ width = 104, className, style, title }: MakaWordmarkProps) {
   return (
+    /* The trace lives on a 460×120 canvas with 27 units of bearing on the left
+       and 21 below the ink; the viewBox is the ink's own box, so `width` is the
+       width of the letters and a left-aligned mark sits on the column edge. */
     <svg
-      viewBox="0 0 460 120"
+      viewBox="27 0 385 99"
       width={width}
       className={className}
       style={style}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org