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/02 14:52:08 UTC

(maka) branch feat/about-page-hierarchy created (now 77199c821f)

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

Astro-Han pushed a change to branch feat/about-page-hierarchy
in repository https://gitbox.apache.org/repos/asf/maka.git


      at 77199c821f refactor(desktop): rebuild the About page as labeled settings groups

This branch includes the following new commits:

     new c5327a3faf fix(desktop): label the About build pill by update channel, not build mode
     new 1504655d3b refactor(desktop): rebuild the About page on the Astryx settings-template anatomy
     new 9b75da3a5f feat(desktop): name the update channel in the diagnostics report
     new 77199c821f refactor(desktop): rebuild the About page as labeled settings groups

The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



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


(maka) 04/04: refactor(desktop): rebuild the About page as labeled settings groups

Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch feat/about-page-hierarchy
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 77199c821fce857281634970dffd18ddc061f777
Author: AstroHan <le...@outlook.com>
AuthorDate: Wed Sep 2 22:51:36 2026 +0800

    refactor(desktop): rebuild the About page as labeled settings groups
    
    The About page was the only settings page that bypassed `SettingsSection`: a bespoke hero with two Badges, three hand-written Dividers, two-column Grids, and a primary button on a support action. The release channel — the one fact a nightly user needs first — was a pill label.
    
    Make the channel the page's lead: a Token next to the product name (nightly orange, dev gray, release unmarked), the version and build as supporting text, and one sentence on what the channel means. Every group below is a `SettingsSection`: update status as a single row, version facts as a bare MetadataList with the workspace path collapsed to `~`, support as three quiet row actions, and the privacy list. No Badge, Grid, Divider, or primary button remains; Astryx's Badge guidance excl [...]
    
    `PageHeader` in `@maka/ui` lost its last caller and is removed with its styles. Stories gain packaged nightly and release variants so both channels render in Storybook; the About e2e and channel tests follow the new structure.
---
 apps/desktop/e2e/about-page.spec.ts                |  20 +-
 .../src/main/__tests__/about-update-status.test.ts |  97 ++++---
 .../renderer/locales/settings-preferences-copy.ts  |  40 ++-
 .../src/renderer/settings/about-settings-page.tsx  | 311 +++++++++------------
 .../src/renderer/settings/about-update-status.ts   |  61 ++--
 .../desktop/src/renderer/styles/settings/about.css |  44 ---
 apps/desktop/src/renderer/styles/sidebar.css       |   5 +-
 .../stories/settings/settings-pages.stories.tsx    |  65 +++++
 packages/ui/src/index.ts                           |   6 -
 packages/ui/src/styles.css                         |   6 -
 10 files changed, 331 insertions(+), 324 deletions(-)

diff --git a/apps/desktop/e2e/about-page.spec.ts b/apps/desktop/e2e/about-page.spec.ts
index 26ca1022df..f9e6714210 100644
--- a/apps/desktop/e2e/about-page.spec.ts
+++ b/apps/desktop/e2e/about-page.spec.ts
@@ -24,16 +24,16 @@ test('About renders channel facts, support, and privacy sections', async ({ wind
   await page.getByRole('button', { name: '设置' }).click();
   await page.getByRole('button', { name: '关于', exact: true }).click();
 
-  // The channel pill must agree with the version string: the fixture app is a
-  // dev checkout, so the pill reads 本地开发版 (with commit), never 正式版.
-  const heroBadge = page.locator('.settingsAboutHeading');
-  await expect(heroBadge.getByText(/本地开发版/)).toBeVisible();
-  await expect(heroBadge.getByText('正式版')).toHaveCount(0);
+  // The channel token must agree with the version string: the fixture app is a
+  // dev checkout, so it reads 本地开发版, never 正式版.
+  await expect(page.getByText('本地开发版', { exact: true }).first()).toBeVisible();
+  await expect(page.getByText('正式版', { exact: true })).toHaveCount(0);
+  await expect(page.getByText('本地开发构建,不检查更新。')).toBeVisible();
 
-  // The archive readout: channel meaning, runtime, and workspace path. The
-  // fixture's workspace is a throwaway temp dir, so assert the code-wrapped
-  // path itself rather than a `~` prefix.
-  await expect(page.getByText('不自动更新')).toBeVisible();
+  // The archive readout: channel, runtime, and workspace path. The fixture's
+  // workspace is a throwaway temp dir, so assert the code-wrapped path itself
+  // rather than a `~` prefix.
+  await expect(page.getByRole('heading', { name: '版本信息' })).toBeVisible();
   await expect(page.getByText(/Electron \d+\.\d+\.\d+/)).toBeVisible();
   await expect(page.locator('dd code')).toBeVisible();
 
@@ -45,7 +45,7 @@ test('About renders channel facts, support, and privacy sections', async ({ wind
 
   // Support lives outside the info conditional: usable even when `app.info`
   // fails, which is exactly when a user reaches for it.
-  await expect(page.getByRole('heading', { name: '支持与诊断' })).toBeVisible();
+  await expect(page.getByRole('heading', { name: '支持', exact: true })).toBeVisible();
   await expect(page.getByRole('button', { name: '复制', exact: true })).toBeEnabled();
   await expect(page.getByRole('button', { name: '查看' })).toBeEnabled();
 
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 2e0043c805..2515939345 100644
--- a/apps/desktop/src/main/__tests__/about-update-status.test.ts
+++ b/apps/desktop/src/main/__tests__/about-update-status.test.ts
@@ -20,54 +20,73 @@
 import assert from 'node:assert/strict';
 import { test } from 'node:test';
 import {
-  aboutChannelBadge,
+  aboutChannelFacts,
+  aboutUpdateStatusDetail,
 } from '../../renderer/settings/about-update-status.js';
 import { getSettingsPreferencesCopy } from '../../renderer/locales/settings-preferences-copy.js';
 
 const copy = getSettingsPreferencesCopy('zh').about;
 
-test('a packaged nightly build is labelled Nightly, never 正式版', () => {
-  const badge = aboutChannelBadge(
-    { buildMode: 'packaged', buildCommit: null, updateChannel: 'nightly' },
-    copy,
-  );
-  assert.deepEqual(badge, {
-    label: 'Nightly',
-    variant: 'orange',
-    channelName: 'Nightly',
-  });
+test('a packaged nightly is tokened Nightly, never 正式版', () => {
+  const facts = aboutChannelFacts({ buildMode: 'packaged', updateChannel: 'nightly' }, copy);
+  assert.equal(facts.key, 'nightly');
+  assert.equal(facts.name, 'Nightly');
+  assert.deepEqual(facts.token, { label: 'Nightly', color: 'orange' });
+  assert.match(facts.summary, /会覆盖正式版安装/);
 });
 
-test('a packaged release build keeps the release pill', () => {
-  const badge = aboutChannelBadge(
-    { buildMode: 'packaged', buildCommit: null, updateChannel: 'release' },
-    copy,
-  );
-  assert.deepEqual(badge, {
-    label: '正式版',
-    variant: 'blue',
-    channelName: '正式版',
-  });
+test('a packaged release wears no token — it is the default state', () => {
+  const facts = aboutChannelFacts({ buildMode: 'packaged', updateChannel: 'release' }, copy);
+  assert.equal(facts.key, 'release');
+  assert.equal(facts.name, '正式版');
+  assert.equal(facts.token, null);
 });
 
-test('a dev checkout carries its commit on a neutral pill', () => {
-  const withCommit = aboutChannelBadge(
-    { buildMode: 'dev', buildCommit: 'abc1234', updateChannel: 'release' },
-    copy,
+test('buildMode decides before updateChannel, whose dev value is a placeholder', () => {
+  const facts = aboutChannelFacts({ buildMode: 'dev', updateChannel: 'nightly' }, copy);
+  assert.equal(facts.key, 'dev');
+  assert.equal(facts.name, '本地开发版');
+  assert.deepEqual(facts.token, { label: '本地开发版', color: 'gray' });
+});
+
+test('a dev build reports why it never updates, whatever the updater says', () => {
+  assert.equal(
+    aboutUpdateStatusDetail(
+      { state: 'downloaded', currentVersion: '0.2.0', latestVersion: '0.2.1' },
+      copy,
+      { isDevBuild: true },
+    ),
+    copy.updateDevBuildHelp,
   );
-  assert.deepEqual(withCommit, {
-    label: '本地开发版 · abc1234',
-    variant: 'neutral',
-    channelName: '本地开发版',
-  });
+});
 
-  const withoutCommit = aboutChannelBadge(
-    { buildMode: 'dev', buildCommit: null, updateChannel: 'nightly' },
-    copy,
+test('the nightly steady states each read as themselves', () => {
+  const detail = (status: Parameters<typeof aboutUpdateStatusDetail>[0]) =>
+    aboutUpdateStatusDetail(status, copy, { isDevBuild: false });
+
+  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%)…',
+  );
+  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 的发布来源…',
   );
-  assert.deepEqual(withoutCommit, {
-    label: '本地开发版',
-    variant: 'neutral',
-    channelName: '本地开发版',
-  });
-});
\ No newline at end of file
+  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 已下载,可在侧栏选择重启安装。',
+  );
+});
diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
index 25d195cc4d..24a511dc80 100644
--- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
@@ -228,15 +228,17 @@ export type SettingsPreferencesCopy = {
     devBuild: string;
     packagedBuild: string;
     nightlyBuild: string;
-    subtitle: string;
+    buildInfoTitle: string;
     channelLabel: string;
+    versionLabel: string;
+    buildLabel: string;
     runtimeLabel: string;
     workspaceLabel: string;
+    /** One sentence saying what following this channel means for the user. */
     channelSummaries: Record<'dev' | 'nightly' | 'release', string>;
     platformNames: Record<string, string>;
-    updatesLede: string;
+    updateStatusLabel: string;
     supportTitle: string;
-    supportLede: string;
     reportIssueHelp: string;
     reportIssueOpen: string;
     copyAction: string;
@@ -352,16 +354,20 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
       passwordSavedPlaceholder: '密码已保存;输入新密码以替换',
     },
     about: {
-      loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制诊断信息', pasteHint: '检查内容后,可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', packagedBuild: '正式版', nightlyBuild: 'Nightly', subtitle: '本地优先的 AI 助手 · 桌面端运行环境',
-      channelLabel: '渠道', runtimeLabel: '运行环境', workspaceLabel: '工作区',
-      channelSummaries: { dev: '不自动更新', nightly: '每晚构建,自动跟随更新', release: '打包安装,自动检查更新' },
+      loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制诊断信息', pasteHint: '检查内容后,可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', packagedBuild: '正式版', nightlyBuild: 'Nightly',
+      buildInfoTitle: '版本信息', channelLabel: '渠道', versionLabel: '版本', buildLabel: '构建', runtimeLabel: '运行环境', workspaceLabel: '工作区',
+      channelSummaries: {
+        dev: '本地开发构建,不检查更新。',
+        nightly: '每日构建的预发布版,自动更新到最新 nightly,会覆盖正式版安装。',
+        release: '正式发布版,自动接收稳定更新。',
+      },
       platformNames: { darwin: 'macOS', win32: 'Windows', linux: 'Linux' },
-      updatesTitle: '软件更新', updatesLede: 'Maka 检查并安装新版本的渠道与状态。',
-      supportTitle: '支持与诊断', supportLede: '出问题时的三件事:取证、上报、查快捷键。',
+      updatesTitle: '软件更新', updateStatusLabel: '更新状态',
+      supportTitle: '支持',
       copying: '复制中…', copyDiagnostics: '复制诊断信息', copyAction: '复制', copyHelp: '复制版本、平台、隐藏主目录后的工作区路径,以及近期脱敏的 Desktop 与 Runtime Host 日志;仅写入剪贴板,不会自动上传。',
       reportIssueLabel: '报告问题', reportIssueHelp: '带上诊断信息去 GitHub Issues,回复更快。', reportIssueOpen: '打开',
       keyboardShortcuts: '键盘快捷键', keyboardShortcutsHelp: 'Maka 支持的全部快捷键一览。', keyboardShortcutsOpen: '查看',
-      privacyLabel: '隐私承诺', privacyTitle: '隐私承诺', privacyLede: 'Maka 对本机数据与通信的三条承诺。', privacyPoints: ['任务、设置、凭据和 Skill 指令文件都留在本机;模型密钥保存在本机凭据文件内,订阅令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权;每个任务都会在本机保留消息、工具调用、权限决策与模式变更记录。'],
+      privacyLabel: '隐私承诺', privacyTitle: '本地优先 · 隐私默认', privacyLede: 'Maka 对本机数据与通信的三条承诺。', privacyPoints: ['任务、设置、凭据和 Skill 指令文件都留在本机;模型密钥保存在本机凭据文件内,订阅令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权;每个任务都会在本机保留消息、工具调用、权限决策与模式变更记录。'],
       checkForUpdates: '检查更新',
       checkingForUpdates: '检查中…',
       updateHelp: '后台也会定期检查;需要重启安装时侧栏会提示。',
@@ -414,16 +420,20 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
       passwordSavedPlaceholder: 'Password saved; enter a new password to replace it',
     },
     about: {
-      loadFailed: 'Could not load About information', loading: 'Loading About', unavailable: 'About information is unavailable', copied: 'Diagnostics copied', pasteHint: 'Review the content, then paste it into an issue report', copyFailed: 'Copy failed', clipboardUnavailable: 'The clipboard is unavailable or access was denied.', devBuild: 'Local development build', packagedBuild: 'Release build', nightlyBuild: 'Nightly', subtitle: 'A local-first AI assistant · Desktop runtime',
-      channelLabel: 'Channel', runtimeLabel: 'Runtime', workspaceLabel: 'Workspace',
-      channelSummaries: { dev: 'no automatic updates', nightly: 'Nightly build, updates follow automatically', release: 'Packaged install, checks for updates automatically' },
+      loadFailed: 'Could not load About information', loading: 'Loading About', unavailable: 'About information is unavailable', copied: 'Diagnostics copied', pasteHint: 'Review the content, then paste it into an issue report', copyFailed: 'Copy failed', clipboardUnavailable: 'The clipboard is unavailable or access was denied.', devBuild: 'Local development build', packagedBuild: 'Release build', nightlyBuild: 'Nightly',
+      buildInfoTitle: 'Version details', channelLabel: 'Channel', versionLabel: 'Version', buildLabel: 'Build', runtimeLabel: 'Runtime', workspaceLabel: 'Workspace',
+      channelSummaries: {
+        dev: 'A local development build. It does not check for updates.',
+        nightly: 'A daily prerelease build. It updates itself to the latest nightly and replaces a release install.',
+        release: 'The official release build. It receives stable updates automatically.',
+      },
       platformNames: { darwin: 'macOS', win32: 'Windows', linux: 'Linux' },
-      updatesTitle: 'Software updates', updatesLede: 'How Maka checks for and installs new versions.',
-      supportTitle: 'Support & diagnostics', supportLede: 'Three things when something breaks: capture, report, look up shortcuts.',
+      updatesTitle: 'Software updates', updateStatusLabel: 'Update status',
+      supportTitle: 'Support',
       copying: 'Copying…', copyDiagnostics: 'Copy diagnostics', copyAction: 'Copy', copyHelp: 'Copy version, platform, a home-redacted workspace path, and recent redacted Desktop and Runtime Host 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: 'Privacy commitments', privacyLede: 'Three commitments about your local data and network use.', 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 t [...]
+      privacyLabel: 'Privacy commitments', privacyTitle: 'Local first · Private by default', privacyLede: 'Three commitments about your local data and network use.', 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 permis [...]
       checkForUpdates: 'Check for updates',
       checkingForUpdates: 'Checking…',
       updateHelp: 'Maka also checks in the background. When a restart is required, the sidebar will prompt you.',
diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx
index 45cc3c1833..b9b15e475c 100644
--- a/apps/desktop/src/renderer/settings/about-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx
@@ -17,11 +17,9 @@
  * under the License.
  */
 
-import { useEffect, useId, useState, type ReactNode } from 'react';
+import { useEffect, useState, type ReactNode } from 'react';
 import {
-  Badge,
-  Divider,
-  Grid,
+  Code,
   Heading,
   HStack,
   Link,
@@ -30,24 +28,18 @@ import {
   MetadataList,
   MetadataListItem,
   Text,
+  Token,
   VStack,
 } from '@astryxdesign/core';
 import { Kbd } from '@astryxdesign/core/Kbd';
 import { Sparkles } from '@maka/ui/icons';
-import {
-  Banner,
-  Button,
-  PageHeader,
-  useMountedRef,
-  useToast,
-  useUiLocale,
-} from '@maka/ui';
+import { Banner, Button, useMountedRef, useToast, useUiLocale } from '@maka/ui';
 import type { AppUpdateStatus } from '../../preload/bridge-contract.js';
-import { SettingsPage } from './settings-section.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 { aboutChannelBadge, aboutUpdateStatusDetail } from './about-update-status.js';
+import { aboutChannelFacts, aboutUpdateStatusDetail } from './about-update-status.js';
 import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy.js';
 import {
   defaultRuntimeHostDiagnosticTarget,
@@ -70,11 +62,6 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
   const checkUpdateGuard = useActionGuard<'check'>();
   const aboutPageMountedRef = useMountedRef();
   const toast = useToast();
-  const diagnosticsHelpId = useId();
-  const updateHelpId = useId();
-  const updatesHeadingId = useId();
-  const supportHeadingId = useId();
-  const privacyHeadingId = useId();
 
   useEffect(() => {
     let cancelled = false;
@@ -154,189 +141,167 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
     }
   }
 
-  let aboutContent: ReactNode;
+  let identity: ReactNode;
   if (!info && !infoError) {
-    aboutContent = (
-      <SettingsSkeletonStack
-        label={copy.loading}
-        lines={[
-          { width: '38%', size: 'lg' },
-          { width: '70%' },
-          { width: '52%' },
-        ]}
-      />
+    identity = (
+      <SettingsSection variant="bare">
+        <SettingsSkeletonStack
+          label={copy.loading}
+          lines={[
+            { width: '38%', size: 'lg' },
+            { width: '70%' },
+            { width: '52%' },
+          ]}
+        />
+      </SettingsSection>
     );
   } else if (!info) {
-    aboutContent = (
-      <Banner
-        status="info"
-        role="alert"
-        title={copy.unavailable}
-        description={infoError} />
+    identity = (
+      <SettingsSection variant="bare">
+        <Banner status="info" role="alert" title={copy.unavailable} description={infoError} />
+      </SettingsSection>
     );
   } else {
-    const channelBadge = aboutChannelBadge(info, copy);
-    const channelKey = info.buildMode === 'dev' ? 'dev' : info.updateChannel;
+    const channel = aboutChannelFacts(info, copy);
     const isDevBuild = info.buildMode === 'dev';
     // The contract hands us `homePath` for exactly this collapse.
     const workspaceDisplay = info.workspacePath.startsWith(info.homePath)
       ? `~${info.workspacePath.slice(info.homePath.length)}`
       : info.workspacePath;
-    aboutContent = (
+    identity = (
       <>
-        <VStack gap={5}>
-          <PageHeader
-            as_wrapper="div"
-            className="settingsAboutHero"
-            as="h2"
-            icon={<Sparkles size={30} /> /* 64% of the 48px plate, matching .providerLogo's fill */}
-            iconClassName="settingsAboutLogo"
-            headingRowClassName="settingsAboutHeading"
-            title="Maka"
-            badge={
-              <>
-                <Badge variant="neutral" label={`v${info.appVersion}`} />
-                <Badge variant={channelBadge.variant} label={channelBadge.label} />
-              </>
-            }
-            subtitle={copy.subtitle}
-            subtitleClassName="settingsAboutTagline"
+        {/* The lead group: what this app is, which channel it follows, and what
+            that means. Unlabeled on purpose — the page title already says 关于,
+            so a heading here would only repeat it. */}
+        <SettingsSection variant="bare">
+          <VStack gap={2}>
+            <HStack gap={4} vAlign="center">
+              <span className="settingsAboutLogo" aria-hidden="true">
+                {/* 64% of the 48px plate, matching .providerLogo's fill */}
+                <Sparkles size={30} />
+              </span>
+              <HStack gap={2} vAlign="center" wrap="wrap">
+                <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>
+            </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>
+        <SettingsSection
+          title={copy.updatesTitle}
+          /* A dev build's own status line already explains why nothing updates,
+             so the background-check sentence must not contradict it. */
+          description={isDevBuild ? undefined : copy.updateHelp}
+        >
+          <SettingsRow
+            label={copy.updateStatusLabel}
+            description={aboutUpdateStatusDetail(updateStatus, copy, { isDevBuild })}
+            end={(
+              <Button
+                variant="secondary"
+                size="sm"
+                isDisabled={checkingUpdate || isDevBuild}
+                onClick={() => void checkForUpdates()}
+                label={checkingUpdate || updateStatus?.state === 'checking'
+                  ? copy.checkingForUpdates
+                  : copy.checkForUpdates}
+              />
+            )}
           />
-          {/* The archive readout: what channel this is, what it runs on, and
-              where the data lives. Astryx's label → value primitive, the same
-              construction the MCP detail panel uses — no hairlines of ours. */}
-          <MetadataList columns="single" label={{ position: 'start', width: 88 }}>
-            <MetadataListItem label={copy.channelLabel}>
-              <Text type="body">
-                {channelBadge.channelName} · {copy.channelSummaries[channelKey]}
-              </Text>
-            </MetadataListItem>
+        </SettingsSection>
+        {/* The archive readout, in Astryx's label → value primitive. No label
+            width: the component sizes the column, so adding a language does not
+            mean re-tuning a pixel count. */}
+        <SettingsSection title={copy.buildInfoTitle} variant="bare">
+          <MetadataList>
+            <MetadataListItem label={copy.channelLabel}>{channel.name}</MetadataListItem>
+            <MetadataListItem label={copy.versionLabel}>{info.appVersion}</MetadataListItem>
+            {info.buildCommit ? (
+              <MetadataListItem label={copy.buildLabel}>{info.buildCommit}</MetadataListItem>
+            ) : null}
             <MetadataListItem label={copy.runtimeLabel}>
-              <Text type="body">
-                {copy.platformNames[info.platform] ?? info.platform} · {info.arch} · Electron {info.electronVersion}
-              </Text>
+              {copy.platformNames[info.platform] ?? info.platform} · {info.arch} · Electron {info.electronVersion}
             </MetadataListItem>
+            {/* Astryx's `Code`, not a raw <code> leaning on the renderer's
+                global monospace rule — a path is read character by character. */}
             <MetadataListItem label={copy.workspaceLabel}>
-              <Text type="body"><code>{workspaceDisplay}</code></Text>
+              <Code>{workspaceDisplay}</Code>
             </MetadataListItem>
           </MetadataList>
-        </VStack>
-        <Divider />
-        <section aria-labelledby={updatesHeadingId}>
-          <Grid columns={{ minWidth: 320, max: 2, repeat: 'fit' }} gap={10}>
-            <VStack gap={1}>
-              <Heading level={3} id={updatesHeadingId}>{copy.updatesTitle}</Heading>
-              <Text type="supporting" size="sm" color="secondary">{copy.updatesLede}</Text>
-            </VStack>
-            <VStack gap={2}>
-              {/* A dev build's status detail IS the explanation (本地开发版不检查
-                  GitHub 发布更新…), so the background-check paragraph must not
-                  repeat it — the old page printed that sentence twice. */}
-              <Text type="body" id={isDevBuild ? updateHelpId : undefined}>
-                {aboutUpdateStatusDetail(updateStatus, copy, { isDevBuild })}
-              </Text>
-              {!isDevBuild && (
-                <Text type="supporting" size="sm" color="secondary" id={updateHelpId}>
-                  {copy.updateHelp}
-                </Text>
-              )}
-              <HStack gap={2}>
-                <Button
-                  variant="secondary"
-                  size="sm"
-                  isDisabled={checkingUpdate || isDevBuild}
-                  aria-describedby={updateHelpId}
-                  onClick={() => void checkForUpdates()}
-                  label={checkingUpdate || updateStatus?.state === 'checking'
-                    ? copy.checkingForUpdates
-                    : copy.checkForUpdates}
-                />
-              </HStack>
-            </VStack>
-          </Grid>
-        </section>
-        <Divider />
+        </SettingsSection>
       </>
     );
   }
 
   return (
     <SettingsPage>
-      {aboutContent}
+      {identity}
       {/* 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. */}
-      <section aria-labelledby={supportHeadingId}>
-        <Grid columns={{ minWidth: 320, max: 2, repeat: 'fit' }} gap={10}>
-          <VStack gap={1}>
-            <Heading level={3} id={supportHeadingId}>{copy.supportTitle}</Heading>
-            <Text type="supporting" size="sm" color="secondary">{copy.supportLede}</Text>
-          </VStack>
-          <VStack gap={5}>
-            <VStack gap={1} align="start">
-              <Text type="label">{copy.copyDiagnostics}</Text>
-              <Text type="supporting" size="sm" color="secondary" id={diagnosticsHelpId}>
-                {copy.copyHelp}
-              </Text>
-              <HStack gap={2}>
-                <Button
-                  variant="primary"
-                  size="sm"
-                  isDisabled={copyingDiagnostics}
-                  aria-describedby={diagnosticsHelpId}
-                  onClick={() => void copyDiagnostics()}
-                  label={copyingDiagnostics ? copy.copying : copy.copyAction}
-                />
-                <Kbd keys="mod+shift+d" />
-              </HStack>
-            </VStack>
-            <VStack gap={1} align="start">
-              <Text type="label">{copy.reportIssueLabel}</Text>
-              <Text type="supporting" size="sm" color="secondary">{copy.reportIssueHelp}</Text>
-              <HStack gap={2}>
-                <Link href={ISSUE_TRACKER_URL} target="_blank" rel="noreferrer noopener">
-                  {copy.reportIssueOpen}
-                </Link>
-              </HStack>
-            </VStack>
-            {props.onOpenKeyboardHelp && (
-              <VStack gap={1} align="start">
-                <Text type="label">{copy.keyboardShortcuts}</Text>
-                <Text type="supporting" size="sm" color="secondary">{copy.keyboardShortcutsHelp}</Text>
-                <HStack gap={2}>
-                  <Button
-                    variant="ghost"
-                    size="sm"
-                    onClick={props.onOpenKeyboardHelp}
-                    label={copy.keyboardShortcutsOpen}
-                  />
-                </HStack>
-              </VStack>
+      <SettingsSection title={copy.supportTitle}>
+        <SettingsRow
+          label={copy.copyDiagnostics}
+          description={copy.copyHelp}
+          end={(
+            <HStack gap={2} vAlign="center">
+              <Button
+                variant="ghost"
+                size="sm"
+                isDisabled={copyingDiagnostics}
+                onClick={() => void copyDiagnostics()}
+                label={copyingDiagnostics ? copy.copying : copy.copyAction}
+              />
+              <Kbd keys="mod+shift+d" />
+            </HStack>
+          )}
+        />
+        <SettingsRow
+          label={copy.reportIssueLabel}
+          description={copy.reportIssueHelp}
+          end={(
+            <Link href={ISSUE_TRACKER_URL} target="_blank" rel="noreferrer noopener">
+              {copy.reportIssueOpen}
+            </Link>
+          )}
+        />
+        {props.onOpenKeyboardHelp ? (
+          <SettingsRow
+            label={copy.keyboardShortcuts}
+            description={copy.keyboardShortcutsHelp}
+            end={(
+              <Button
+                variant="ghost"
+                size="sm"
+                onClick={props.onOpenKeyboardHelp}
+                label={copy.keyboardShortcutsOpen}
+              />
             )}
-          </VStack>
-        </Grid>
-      </section>
-      {info && (
-        <>
-          <Divider />
-          <section aria-labelledby={privacyHeadingId}>
-            <Grid columns={{ minWidth: 320, max: 2, repeat: 'fit' }} gap={10}>
-              <VStack gap={1}>
-                <Heading level={3} id={privacyHeadingId}>{copy.privacyTitle}</Heading>
-                <Text type="supporting" size="sm" color="secondary">{copy.privacyLede}</Text>
-              </VStack>
-              <List aria-label={copy.privacyLabel} density="compact" listStyle="disc">
-                {/* Fragment-wrapped: ListItem single-line-truncates STRING labels,
-                    and a privacy commitment must wrap, not ellipsize. */}
-                {copy.privacyPoints.map((point) => <ListItem key={point} label={<>{point}</>} />)}
-              </List>
-            </Grid>
-          </section>
-        </>
-      )}
+          />
+        ) : null}
+      </SettingsSection>
+      {info ? (
+        <SettingsSection title={copy.privacyTitle} description={copy.privacyLede} variant="bare">
+          <List aria-label={copy.privacyLabel} density="compact" listStyle="disc">
+            {/* Fragment-wrapped: ListItem single-line-truncates STRING labels,
+                and a privacy commitment must wrap, not ellipsize. */}
+            {copy.privacyPoints.map((point) => <ListItem key={point} label={<>{point}</>} />)}
+          </List>
+        </SettingsSection>
+      ) : null}
     </SettingsPage>
   );
-}
\ No newline at end of file
+}
diff --git a/apps/desktop/src/renderer/settings/about-update-status.ts b/apps/desktop/src/renderer/settings/about-update-status.ts
index 1f02f2a948..0a101b8e0e 100644
--- a/apps/desktop/src/renderer/settings/about-update-status.ts
+++ b/apps/desktop/src/renderer/settings/about-update-status.ts
@@ -22,43 +22,48 @@ import type { SettingsPreferencesCopy } from '../locales/settings-preferences-co
 
 type AboutCopy = SettingsPreferencesCopy['about'];
 
-/** The channel pill's Astryx Badge variant, narrow enough to assign directly. */
-export type AboutChannelBadgeVariant = 'neutral' | 'blue' | 'orange';
+export type AboutChannelKey = 'dev' | 'nightly' | 'release';
 
-export interface AboutChannelBadge {
-  readonly label: string;
-  readonly variant: AboutChannelBadgeVariant;
-  /** The bare channel name without the dev commit suffix — for readouts that
-   *  already sit next to the version pill and don't need it repeated. */
-  readonly channelName: string;
+export interface AboutChannelFacts {
+  readonly key: AboutChannelKey;
+  /** The channel's own name, as the 版本信息 readout and the lead Token use it. */
+  readonly name: string;
+  /** One sentence saying what following this channel means. */
+  readonly summary: string;
+  /**
+   * The lead `Token`, or null. A release install is the default state and gets
+   * no mark: Astryx reserves colour for what departs from it, so tokening every
+   * channel would make none of them stand out.
+   */
+  readonly token: { readonly label: string; readonly color: 'orange' | 'gray' } | null;
 }
 
 /**
- * What the version pill next to "Maka" actually says, pure for unit tests.
+ * What the About lead says about this build's channel, pure for unit tests.
  *
  * Build mode and release channel answer different questions: `buildMode` says
  * how this binary was produced (a checkout vs a packaged install), while
- * `updateChannel` says which release feed it follows. A packaged nightly is a
- * real install but NOT a release, so the old "packaged → 正式版" mapping lied
- * to exactly the users running nightly builds. Dev mode keeps the commit in
- * the label and drops to `neutral`: a checkout is not a release artifact
- * either, so it must not wear the release blue.
+ * `updateChannel` says which release feed it follows. A dev checkout follows no
+ * feed at all — its `updateChannel` is the updater's `release` placeholder — so
+ * `buildMode` decides first, and the old "packaged → 正式版" mapping that lied
+ * to nightly users stays gone.
  */
-export function aboutChannelBadge(
-  info: Pick<DesktopAppInfo, 'buildMode' | 'buildCommit' | 'updateChannel'>,
+export function aboutChannelFacts(
+  info: Pick<DesktopAppInfo, 'buildMode' | 'updateChannel'>,
   copy: AboutCopy,
-): AboutChannelBadge {
-  if (info.buildMode === 'dev') {
-    return {
-      label: info.buildCommit ? `${copy.devBuild} · ${info.buildCommit}` : copy.devBuild,
-      variant: 'neutral',
-      channelName: copy.devBuild,
-    };
-  }
-  if (info.updateChannel === 'nightly') {
-    return { label: copy.nightlyBuild, variant: 'orange', channelName: copy.nightlyBuild };
-  }
-  return { label: copy.packagedBuild, variant: 'blue', channelName: copy.packagedBuild };
+): AboutChannelFacts {
+  const key: AboutChannelKey = info.buildMode === 'dev' ? 'dev' : info.updateChannel;
+  const name = key === 'nightly'
+    ? copy.nightlyBuild
+    : key === 'dev'
+      ? copy.devBuild
+      : copy.packagedBuild;
+  return {
+    key,
+    name,
+    summary: copy.channelSummaries[key],
+    token: key === 'release' ? null : { label: name, color: key === 'nightly' ? 'orange' : 'gray' },
+  };
 }
 
 /** Map updater state to About-page detail copy (pure for unit tests). */
diff --git a/apps/desktop/src/renderer/styles/settings/about.css b/apps/desktop/src/renderer/styles/settings/about.css
index f7774c76fa..28cd1da23c 100644
--- a/apps/desktop/src/renderer/styles/settings/about.css
+++ b/apps/desktop/src/renderer/styles/settings/about.css
@@ -22,20 +22,6 @@
    the About-only `.settingsAboutPage` grid (16px rhythm, breaking the shared
    32px page rhythm) is retired. */
 
-.settingsAboutHero {
-  display: grid;
-  grid-template-columns: 48px minmax(0, 1fr);
-  align-items: center;
-  gap: var(--space-4);
-  padding: 0;
-  border: 0;
-  /* Not a shape decision: this is a bare layout grid — transparent, borderless,
-     unpadded — so it paints no box for a corner to appear on. The declarations
-     strip inherited chrome; DESIGN.md §6 governs boxes that render. */
-  border-radius: 0;
-  background: transparent;
-}
-
 .settingsAboutLogo {
   width: 48px;
   height: 48px;
@@ -52,34 +38,4 @@
   color: var(--brand-deep);
 }
 
-.settingsAboutHeading {
-  display: flex;
-  align-items: center;
-  flex-wrap: wrap;
-  gap: var(--space-2-5);
-  margin: 0 0 var(--space-1);
-}
-
-.settingsAboutHeading h2 {
-  font: var(--maka-text-heading-2);
-  margin: 0;
-  color: var(--foreground);
-  letter-spacing: var(--tracking-normal);
-}
-
-/* #1879: both hero pills are Astryx `Badge`s, and neither has a product rule
-   left. The version keeps the mono stack — a version string is read digit by
-   digit — but takes it from being a `<code>` inside the badge rather than from
-   a rule here: `:where(code, kbd, samp, pre)` in maka-tokens.css is the one
-   monospace authority in the renderer, and it composes the roles a second time
-   against that family, so nothing else about the badge's type moves. The
-   channel pill was a hand-tinted `--brand-deep` wash; `blue` is the tinted
-   variant that keeps it distinguishable from the neutral version pill. */
-
-.settingsAboutTagline {
-  font: var(--maka-text-body);
-  margin: 0;
-  color: var(--muted-foreground);
-}
-
 
diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css
index 87cff5a137..67b7ddc359 100644
--- a/apps/desktop/src/renderer/styles/sidebar.css
+++ b/apps/desktop/src/renderer/styles/sidebar.css
@@ -524,9 +524,8 @@
   margin-left: auto;
   padding: 0;
   border: 0;
-  /* Same as .settingsAboutHero: un-chroming a <kbd>, not squaring a box. With
-     no padding, border, fill or shadow there is nothing for a radius to act
-     on. */
+  /* Un-chroming a <kbd>, not squaring a box. With no padding, border, fill or
+     shadow there is nothing for a radius to act on. */
   border-radius: 0;
   background: transparent;
   box-shadow: none;
diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index d810b0b4c8..afa5b36dbd 100644
--- a/apps/desktop/stories/settings/settings-pages.stories.tsx
+++ b/apps/desktop/stories/settings/settings-pages.stories.tsx
@@ -41,6 +41,7 @@ import type {
 } from '@maka/core/capabilities';
 import type { HealthSignal, HealthSnapshot } from '@maka/core/health';
 import type { DesktopExternalSessionCatalogItem } from '../../src/preload/external-session-catalog';
+import type { AppUpdateStatus } from '../../src/preload/bridge-contract';
 import type { SessionSummary } from '@maka/core/session';
 import { revisionFamilySessionIds } from '@maka/core/session-revisions';
 import type {
@@ -847,6 +848,8 @@ const makaBridge = {
       osRelease: '23.4.0',
       arch: 'arm64',
       buildMode: 'dev',
+      // Dev installs report the updater's release fallback, not a real channel.
+      updateChannel: 'release',
       buildCommit: 'a63ae4d',
       appVersion: '0.9.0-dev',
       electronVersion: '33.2.0',
@@ -857,6 +860,9 @@ const makaBridge = {
       // deep so the mono value exercises its wrap contract.
       workspacePath:
         '/Users/storybook-fixture-user/Library/Application Support/Maka/workspaces/infra-observability-platform-desktop',
+      // The prefix About collapses to `~`. Without it the story showed the
+      // absolute path, which is not what any user sees.
+      homePath: '/Users/storybook-fixture-user',
     }),
     openPath: async () => ({ ok: true as const, opened: '/Users/storybook' }),
     // The 外观 page mounts the app-icon picker as soon as it opens. Without
@@ -964,6 +970,32 @@ const makaBridge = {
 
 const withSettingsBridge = withScopedMakaBridge(makaBridge);
 
+/**
+ * A PACKAGED install, which the shared fixture cannot be: it is a dev checkout,
+ * and `buildMode` short-circuits the About lead before `updateChannel` is ever
+ * read. Only these two facts move — everything else stays the shared bridge, so
+ * the channel stories differ from `About` by exactly what they are about.
+ */
+function withPackagedChannelBridge(channel: {
+  updateChannel: 'nightly' | 'release';
+  appVersion: string;
+  updateStatus: AppUpdateStatus;
+}) {
+  return withScopedMakaBridge({
+    ...makaBridge,
+    app: {
+      ...makaBridge.app,
+      info: async () => ({
+        ...(await makaBridge.app.info()),
+        buildMode: 'packaged' as const,
+        updateChannel: channel.updateChannel,
+        appVersion: channel.appVersion,
+      }),
+      updateStatus: async () => channel.updateStatus,
+    },
+  } satisfies Record<string, unknown>);
+}
+
 function pendingForever<T>(): Promise<T> {
   return new Promise(() => undefined);
 }
@@ -1670,6 +1702,7 @@ function SettingsStoryFrame(props: SettingsStoryProps) {
               initialConnectionSlug={props.initialConnectionSlug}
               initialFocusRef={initialFocusRef}
               onOpenDailyReview={noop}
+              onOpenKeyboardHelp={noop}
               onOpenSession={noop}
               archivedTasks={archivedTasks}
               onTaskImported={noop}
@@ -2584,6 +2617,38 @@ export const About: Story = {
   render: () => <SettingsStory section="about" />,
 };
 
+// Real path: the same page inside a packaged Nightly. Nightly publishes daily
+// and auto-downloads, so `downloaded` — not `not-available` — is what a nightly
+// user actually opens this page to. The version string is the shipped shape:
+// <product>-dev.<run>.<UTC day>.
+export const AboutNightly: Story = {
+  decorators: [
+    withPackagedChannelBridge({
+      updateChannel: 'nightly',
+      appVersion: '0.2.0-dev.12.20260901',
+      updateStatus: {
+        state: 'downloaded',
+        currentVersion: '0.2.0-dev.12.20260901',
+        latestVersion: '0.2.0-dev.13.20260902',
+      },
+    }),
+  ],
+  render: () => <SettingsStory section="about" />,
+};
+
+// Real path: the same page inside a packaged release — the default state, which
+// wears no channel token at all.
+export const AboutRelease: Story = {
+  decorators: [
+    withPackagedChannelBridge({
+      updateChannel: 'release',
+      appVersion: '0.2.0',
+      updateStatus: { state: 'not-available', currentVersion: '0.2.0' },
+    }),
+  ],
+  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/index.ts b/packages/ui/src/index.ts
index 7942da832c..2ebac40575 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -96,12 +96,6 @@ export * from './primitives/stat-tile.js';
 // slot, implementation swapped behind it. badgeVariants retired with the cva
 // recipe (no consumers).
 export { Badge, type BadgeProps, type BadgeVariant } from '@astryxdesign/core';
-// PageHeader — the shared page-header shell (convergence round 3). One shell
-// for the module hero (as='h2': 技能 / 定时任务) and the settings intros
-// (as='h3': permission / health / about). Wrapper class + per-slot
-// CSS stay at the call site; the primitive converges STRUCTURE only.
-export { PageHeader } from './primitives/page-header.js';
-export type { PageHeaderProps } from './primitives/page-header.js';
 // ModulePage — the ONE shell every module page renders into (Astryx Layout,
 // incident-console archetype). Born in this package for 定时任务 / 每日回顾;
 // exported so the renderer-owned MCP page renders the same surface.
diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css
index 613c843667..659d9415f7 100644
--- a/packages/ui/src/styles.css
+++ b/packages/ui/src/styles.css
@@ -82,12 +82,6 @@
 .maka-quote-chip-remove:hover { color: var(--foreground); background: var(--state-hover-bg); }
 .maka-quote-chip-remove:focus-visible { color: var(--foreground); background: var(--state-hover-bg); outline: var(--focus-ring-width) solid var(--focus-ring); outline-offset: 1px; }
 
-.maka-page-header-title,
-.maka-page-header-eyebrow,
-.maka-page-header-subtitle { margin: 0; }
-.maka-page-header-content { min-width: 0; }
-.maka-page-header-heading-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
-
 
 /* One shape. The tone and emphasis modifiers that used to sit here — a filled
    plate, four tinted borders, four value inks, a dimmed zero — were reachable


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


(maka) 01/04: fix(desktop): label the About build pill by update channel, not build mode

Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch feat/about-page-hierarchy
in repository https://gitbox.apache.org/repos/asf/maka.git

commit c5327a3faf16f8210212295788f41d13fe0e7241
Author: AstroHan <le...@outlook.com>
AuthorDate: Wed Sep 2 12:17:12 2026 +0800

    fix(desktop): label the About build pill by update channel, not build mode
    
    The About page mapped `buildMode === 'packaged'` to 正式版, so a packaged
    nightly install (version `0.2.0-dev.<run>.<date>`) wore a release badge that
    contradicted its own version string. Build mode and release channel answer
    different questions - how the binary was produced vs which feed it follows -
    and the page only ever saw the former.
    
    `app:info` now carries `updateChannel`, resolved from the packaged manifest by
    the same `desktopUpdateChannelFromManifest` parse that drives the updater, so
    the pill and the update feed share one authority. The pill logic moves into a
    pure `aboutChannelBadge` helper: nightly → Nightly (orange), release → 正式版
    (blue), dev → 本地开发版 · commit (neutral, a checkout is not a release
    artifact either).
    
    Generated-by: Maka
---
 .../src/main/__tests__/about-channel-badge.test.ts | 55 ++++++++++++++++++++
 apps/desktop/src/main/app-ipc-main.ts              |  5 +-
 apps/desktop/src/main/runtime-host-boot.ts         |  1 +
 apps/desktop/src/preload/bridge-contract.d.ts      |  4 ++
 .../renderer/locales/settings-preferences-copy.ts  |  5 +-
 .../src/renderer/settings/about-channel-badge.ts   | 58 ++++++++++++++++++++++
 .../src/renderer/settings/about-settings-page.tsx  | 11 ++--
 7 files changed, 128 insertions(+), 11 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/about-channel-badge.test.ts b/apps/desktop/src/main/__tests__/about-channel-badge.test.ts
new file mode 100644
index 0000000000..d083da9b1f
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/about-channel-badge.test.ts
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { aboutChannelBadge } from '../../renderer/settings/about-channel-badge.js';
+import { getSettingsPreferencesCopy } from '../../renderer/locales/settings-preferences-copy.js';
+
+const copy = getSettingsPreferencesCopy('zh').about;
+
+test('a packaged nightly build is labelled Nightly, never 正式版', () => {
+  const badge = aboutChannelBadge(
+    { buildMode: 'packaged', buildCommit: null, updateChannel: 'nightly' },
+    copy,
+  );
+  assert.deepEqual(badge, { label: 'Nightly', variant: 'orange' });
+});
+
+test('a packaged release build keeps the release pill', () => {
+  const badge = aboutChannelBadge(
+    { buildMode: 'packaged', buildCommit: null, updateChannel: 'release' },
+    copy,
+  );
+  assert.deepEqual(badge, { label: '正式版', variant: 'blue' });
+});
+
+test('a dev checkout carries its commit on a neutral pill', () => {
+  const withCommit = aboutChannelBadge(
+    { buildMode: 'dev', buildCommit: 'abc1234', updateChannel: 'release' },
+    copy,
+  );
+  assert.deepEqual(withCommit, { label: '本地开发版 · abc1234', variant: 'neutral' });
+
+  const withoutCommit = aboutChannelBadge(
+    { buildMode: 'dev', buildCommit: null, updateChannel: 'nightly' },
+    copy,
+  );
+  assert.deepEqual(withoutCommit, { label: '本地开发版', variant: 'neutral' });
+});
\ No newline at end of file
diff --git a/apps/desktop/src/main/app-ipc-main.ts b/apps/desktop/src/main/app-ipc-main.ts
index ceb9ac4dd2..0260ccf205 100644
--- a/apps/desktop/src/main/app-ipc-main.ts
+++ b/apps/desktop/src/main/app-ipc-main.ts
@@ -26,6 +26,7 @@ import type { ProjectRootController } from './project-root-controller.js';
 import { resolveOpenPath, type OpenPathResult } from './open-path-guard.js';
 import { getE2eFixtureState, type resolveE2eFixture } from './e2e-fixture.js';
 import type { resolveBuildInfo } from './build-info.js';
+import type { DesktopUpdateChannel } from './app-update-attestation.js';
 import type {
   AppUpdateInstallRequest,
   AppUpdateService,
@@ -47,6 +48,7 @@ export interface AppIpcDeps {
   getProjectRoot(sessionId: unknown): Promise<string>;
   workspaceRoot: string;
   buildInfo: BuildInfo;
+  updateChannel: DesktopUpdateChannel;
   e2eFixture: E2eFixture;
   projectManagement: ProjectManagementService;
   allowLocalProjectPaths?: boolean;
@@ -89,7 +91,7 @@ export function registerAppIpc(
   deps: AppIpcDeps,
   targetIpc: ReconnectableReadIpcMain = ipcMain,
 ): void {
-  const { projectRoot, workspaceRoot, buildInfo, e2eFixture } = deps;
+  const { projectRoot, workspaceRoot, buildInfo, updateChannel, e2eFixture } = deps;
   const allowLocalProjectPaths = deps.allowLocalProjectPaths !== false;
   // Call-time read of the shared project-root authority: every handler must
   // observe the latest selection, not a snapshot taken at registration.
@@ -120,6 +122,7 @@ export function registerAppIpc(
         : { isGitRepo: false },
       buildMode: buildInfo.mode,
       buildCommit: buildInfo.commit,
+      updateChannel,
     };
   });
   handleReconnectableRead(targetIpc, 'projects:getSnapshot', () =>
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index cee0633822..75e05259fb 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -1530,6 +1530,7 @@ function registerHostClientIpc(
       getProjectRoot: resolveProjectRootForContext,
       workspaceRoot,
       buildInfo,
+      updateChannel: desktopUpdateChannel,
       e2eFixture,
       projectManagement: targetProjectManagement,
       allowLocalProjectPaths: !usesHostWorkspace,
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index f825aaf1cb..8c4c5431d9 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -711,6 +711,10 @@ export interface DesktopAppInfo {
   readonly projectGit: { readonly isGitRepo: boolean; readonly branch?: string };
   readonly buildMode: 'dev' | 'packaged';
   readonly buildCommit: string | null;
+  /** Packaged update channel, resolved from the packaged manifest — the same
+   *  authority the updater itself reads. 'release' is also the dev-mode
+   *  fallback, where the channel is meaningless. */
+  readonly updateChannel: 'release' | 'nightly';
 }
 
 /**
diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
index 3c742388d7..65f904784b 100644
--- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
@@ -227,6 +227,7 @@ export type SettingsPreferencesCopy = {
     clipboardUnavailable: string;
     devBuild: string;
     packagedBuild: string;
+    nightlyBuild: string;
     subtitle: string;
     privacyLabel: string;
     privacyTitle: string;
@@ -339,7 +340,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
       passwordSavedPlaceholder: '密码已保存;输入新密码以替换',
     },
     about: {
-      loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制诊断信息', pasteHint: '检查内容后,可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', packagedBuild: '正式版', subtitle: '本地优先的 AI 助手 · 桌面端运行环境', privacyLabel: '隐私与安全', privacyTitle: '本地优先 · 隐私默认', privacyPoints: ['所有任务、设置、凭据和 Skill 指令文件都保留在本机工作区。', '模型密钥保存在本机凭据文件内;订阅账号令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权。', '每个任务都会在本机保留消息、工具调用、权限决策与模式变更记录。'],  [...]
+      loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制诊断信息', pasteHint: '检查内容后,可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', packagedBuild: '正式版', nightlyBuild: 'Nightly', subtitle: '本地优先的 AI 助手 · 桌面端运行环境', privacyLabel: '隐私与安全', privacyTitle: '本地优先 · 隐私默认', privacyPoints: ['所有任务、设置、凭据和 Skill 指令文件都保留在本机工作区。', '模型密钥保存在本机凭据文件内;订阅账号令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权。', '每个任务都会在本机保 [...]
       updatesTitle: '软件更新',
       checkForUpdates: '检查更新',
       checkingForUpdates: '检查中…',
@@ -393,7 +394,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
       passwordSavedPlaceholder: 'Password saved; enter a new password to replace it',
     },
     about: {
-      loadFailed: 'Could not load About information', loading: 'Loading About', unavailable: 'About information is unavailable', copied: 'Diagnostics copied', pasteHint: 'Review the content, then paste it into an issue report', copyFailed: 'Copy failed', clipboardUnavailable: 'The clipboard is unavailable or access was denied.', devBuild: 'Local development build', packagedBuild: 'Release build', subtitle: 'A local-first AI assistant · Desktop runtime', privacyLabel: 'Privacy and securit [...]
+      loadFailed: 'Could not load About information', loading: 'Loading About', unavailable: 'About information is unavailable', copied: 'Diagnostics copied', pasteHint: 'Review the content, then paste it into an issue report', copyFailed: 'Copy failed', clipboardUnavailable: 'The clipboard is unavailable or access was denied.', devBuild: 'Local development build', packagedBuild: 'Release build', nightlyBuild: 'Nightly', subtitle: 'A local-first AI assistant · Desktop runtime', privacyLa [...]
       updatesTitle: 'Software updates',
       checkForUpdates: 'Check for updates',
       checkingForUpdates: 'Checking…',
diff --git a/apps/desktop/src/renderer/settings/about-channel-badge.ts b/apps/desktop/src/renderer/settings/about-channel-badge.ts
new file mode 100644
index 0000000000..3d54463cb6
--- /dev/null
+++ b/apps/desktop/src/renderer/settings/about-channel-badge.ts
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { DesktopAppInfo } from '../../preload/bridge-contract.js';
+import type { SettingsPreferencesCopy } from '../locales/settings-preferences-copy.js';
+
+type AboutCopy = SettingsPreferencesCopy['about'];
+
+/** The channel pill's Astryx Badge variant, narrow enough to assign directly. */
+export type AboutChannelBadgeVariant = 'neutral' | 'blue' | 'orange';
+
+export interface AboutChannelBadge {
+  readonly label: string;
+  readonly variant: AboutChannelBadgeVariant;
+}
+
+/**
+ * What the version pill next to "Maka" actually says, pure for unit tests.
+ *
+ * Build mode and release channel answer different questions: `buildMode` says
+ * how this binary was produced (a checkout vs a packaged install), while
+ * `updateChannel` says which release feed it follows. A packaged nightly is a
+ * real install but NOT a release, so the old "packaged → 正式版" mapping lied
+ * to exactly the users running nightly builds. Dev mode keeps the commit in
+ * the label and drops to `neutral`: a checkout is not a release artifact
+ * either, so it must not wear the release blue.
+ */
+export function aboutChannelBadge(
+  info: Pick<DesktopAppInfo, 'buildMode' | 'buildCommit' | 'updateChannel'>,
+  copy: AboutCopy,
+): AboutChannelBadge {
+  if (info.buildMode === 'dev') {
+    return {
+      label: info.buildCommit ? `${copy.devBuild} · ${info.buildCommit}` : copy.devBuild,
+      variant: 'neutral',
+    };
+  }
+  if (info.updateChannel === 'nightly') {
+    return { label: copy.nightlyBuild, variant: 'orange' };
+  }
+  return { label: copy.packagedBuild, variant: 'blue' };
+}
\ No newline at end of file
diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx
index 6b5c0c056e..8abc5e2899 100644
--- a/apps/desktop/src/renderer/settings/about-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx
@@ -36,6 +36,7 @@ import { settingsActionErrorMessage } from './settings-error-copy.js';
 import { SettingsSkeletonStack } from './settings-skeleton.js';
 import { useActionGuard } from './use-action-guard.js';
 import { aboutUpdateStatusDetail } from './about-update-status.js';
+import { aboutChannelBadge } from './about-channel-badge.js';
 import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy.js';
 import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js';
 import {
@@ -161,6 +162,7 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
         description={infoError} />
     );
   } else {
+    const channelBadge = aboutChannelBadge(info, copy);
     aboutContent = (
       <>
         <PageHeader
@@ -174,14 +176,7 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
           badge={
             <>
               <Badge variant="neutral" label={`v${info.appVersion}`} />
-              <Badge
-                variant="blue"
-                label={info.buildMode === 'dev'
-                  ? info.buildCommit
-                    ? `${copy.devBuild} · ${info.buildCommit}`
-                    : copy.devBuild
-                  : copy.packagedBuild}
-              />
+              <Badge variant={channelBadge.variant} label={channelBadge.label} />
             </>
           }
           subtitle={copy.subtitle}


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


(maka) 02/04: refactor(desktop): rebuild the About page on the Astryx settings-template anatomy

Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch feat/about-page-hierarchy
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 1504655d3b0a5e30bbdaba94ce76eb1e8325d0c2
Author: AstroHan <le...@outlook.com>
AuthorDate: Wed Sep 2 12:25:53 2026 +0800

    refactor(desktop): rebuild the About page on the Astryx settings-template anatomy
    
    The old page had no focus: five privacy bullets opened it, the actions a user
    actually needs were scattered (update check mid-page, diagnostics + shortcut +
    issue link crammed into one bottom row), and the page promised 运行环境 without
    showing any. A dev build also printed the same 本地开发版不检查 GitHub 发布
    更新 sentence twice - once as row detail, once as help text.
    
    The page now follows the vendored Astryx pages/settings template anatomy:
    two-column Grid sections (Heading 3 + supporting lede left, content right) with
    a bare Divider between sections, and no row hairlines - the kit makes dividers
    opt-in everywhere. Facts (channel / runtime / workspace) render as a
    MetadataList, the same label-to-value construction the MCP detail panel uses.
    Order follows the page's real jobs: identity facts, software updates, support
    and diagnostics (merged from the old 参考 + 版本信息 sections), privacy last,
    compressed from five bullets to three. Support stays outside the info
    conditional so diagnostics remain copyable when app.info fails - the state the
    existing SSR test locks in.
    
    The channel pill's helper moves into about-update-status.ts instead of a new
    module: the architecture ledger forbids growing the legacy AppShell closure,
    and this was that module's seam already. The ledger shrinks by the two
    dependencies the page no longer imports.
    
    Generated-by: Maka
---
 apps/desktop/e2e/about-page.spec.ts                |  55 ++++
 apps/desktop/renderer-architecture.json            |   2 -
 ...l-badge.test.ts => about-update-status.test.ts} |  28 +-
 .../renderer/locales/settings-preferences-copy.ts  |  36 ++-
 .../src/renderer/locales/settings-shared-copy.ts   |   6 -
 .../src/renderer/settings/about-channel-badge.ts   |  58 -----
 .../src/renderer/settings/about-settings-page.tsx  | 282 +++++++++++++--------
 .../src/renderer/settings/about-update-status.ts   |  41 ++-
 8 files changed, 332 insertions(+), 176 deletions(-)

diff --git a/apps/desktop/e2e/about-page.spec.ts b/apps/desktop/e2e/about-page.spec.ts
new file mode 100644
index 0000000000..26ca1022df
--- /dev/null
+++ b/apps/desktop/e2e/about-page.spec.ts
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ensureSidebarExpanded, expect, test } from './fixtures';
+
+test('About renders channel facts, support, and privacy sections', async ({ window: page }) => {
+  await ensureSidebarExpanded(page);
+  await page.getByRole('button', { name: '设置' }).click();
+  await page.getByRole('button', { name: '关于', exact: true }).click();
+
+  // The channel pill must agree with the version string: the fixture app is a
+  // dev checkout, so the pill reads 本地开发版 (with commit), never 正式版.
+  const heroBadge = page.locator('.settingsAboutHeading');
+  await expect(heroBadge.getByText(/本地开发版/)).toBeVisible();
+  await expect(heroBadge.getByText('正式版')).toHaveCount(0);
+
+  // The archive readout: channel meaning, runtime, and workspace path. The
+  // fixture's workspace is a throwaway temp dir, so assert the code-wrapped
+  // path itself rather than a `~` prefix.
+  await expect(page.getByText('不自动更新')).toBeVisible();
+  await expect(page.getByText(/Electron \d+\.\d+\.\d+/)).toBeVisible();
+  await expect(page.locator('dd code')).toBeVisible();
+
+  // Dev builds do not poll GitHub releases; the check stays disabled and the
+  // copy says why instead of pretending "已是最新版本".
+  await expect(page.getByRole('heading', { name: '软件更新' })).toBeVisible();
+  await expect(page.getByText('本地开发版不检查 GitHub 发布更新。请使用正式安装包。')).toBeVisible();
+  await expect(page.getByRole('button', { name: '检查更新' })).toBeDisabled();
+
+  // Support lives outside the info conditional: usable even when `app.info`
+  // fails, which is exactly when a user reaches for it.
+  await expect(page.getByRole('heading', { name: '支持与诊断' })).toBeVisible();
+  await expect(page.getByRole('button', { name: '复制', exact: true })).toBeEnabled();
+  await expect(page.getByRole('button', { name: '查看' })).toBeEnabled();
+
+  // Three commitments, not the old wall of five bullets.
+  const privacyList = page.getByRole('list', { name: '隐私承诺' });
+  await expect(privacyList.getByRole('listitem')).toHaveCount(3);
+});
\ No newline at end of file
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 77fd99edad..52caaeb730 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -2585,10 +2585,8 @@
           "../../preload/bridge-contract.js": 1,
           "../default-runtime-host-operation.js": 1,
           "../locales/settings-preferences-copy.js": 1,
-          "../locales/settings-shared-copy.js": 1,
           "./about-update-status.js": 1,
           "./settings-error-copy.js": 1,
-          "./settings-rows.js": 1,
           "./settings-section.js": 1,
           "./settings-skeleton.js": 1,
           "./use-action-guard.js": 1,
diff --git a/apps/desktop/src/main/__tests__/about-channel-badge.test.ts b/apps/desktop/src/main/__tests__/about-update-status.test.ts
similarity index 75%
rename from apps/desktop/src/main/__tests__/about-channel-badge.test.ts
rename to apps/desktop/src/main/__tests__/about-update-status.test.ts
index d083da9b1f..2e0043c805 100644
--- a/apps/desktop/src/main/__tests__/about-channel-badge.test.ts
+++ b/apps/desktop/src/main/__tests__/about-update-status.test.ts
@@ -19,7 +19,9 @@
 
 import assert from 'node:assert/strict';
 import { test } from 'node:test';
-import { aboutChannelBadge } from '../../renderer/settings/about-channel-badge.js';
+import {
+  aboutChannelBadge,
+} from '../../renderer/settings/about-update-status.js';
 import { getSettingsPreferencesCopy } from '../../renderer/locales/settings-preferences-copy.js';
 
 const copy = getSettingsPreferencesCopy('zh').about;
@@ -29,7 +31,11 @@ test('a packaged nightly build is labelled Nightly, never 正式版', () => {
     { buildMode: 'packaged', buildCommit: null, updateChannel: 'nightly' },
     copy,
   );
-  assert.deepEqual(badge, { label: 'Nightly', variant: 'orange' });
+  assert.deepEqual(badge, {
+    label: 'Nightly',
+    variant: 'orange',
+    channelName: 'Nightly',
+  });
 });
 
 test('a packaged release build keeps the release pill', () => {
@@ -37,7 +43,11 @@ test('a packaged release build keeps the release pill', () => {
     { buildMode: 'packaged', buildCommit: null, updateChannel: 'release' },
     copy,
   );
-  assert.deepEqual(badge, { label: '正式版', variant: 'blue' });
+  assert.deepEqual(badge, {
+    label: '正式版',
+    variant: 'blue',
+    channelName: '正式版',
+  });
 });
 
 test('a dev checkout carries its commit on a neutral pill', () => {
@@ -45,11 +55,19 @@ test('a dev checkout carries its commit on a neutral pill', () => {
     { buildMode: 'dev', buildCommit: 'abc1234', updateChannel: 'release' },
     copy,
   );
-  assert.deepEqual(withCommit, { label: '本地开发版 · abc1234', variant: 'neutral' });
+  assert.deepEqual(withCommit, {
+    label: '本地开发版 · abc1234',
+    variant: 'neutral',
+    channelName: '本地开发版',
+  });
 
   const withoutCommit = aboutChannelBadge(
     { buildMode: 'dev', buildCommit: null, updateChannel: 'nightly' },
     copy,
   );
-  assert.deepEqual(withoutCommit, { label: '本地开发版', variant: 'neutral' });
+  assert.deepEqual(withoutCommit, {
+    label: '本地开发版',
+    variant: 'neutral',
+    channelName: '本地开发版',
+  });
 });
\ No newline at end of file
diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
index 65f904784b..25d195cc4d 100644
--- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
@@ -229,6 +229,18 @@ export type SettingsPreferencesCopy = {
     packagedBuild: string;
     nightlyBuild: string;
     subtitle: string;
+    channelLabel: string;
+    runtimeLabel: string;
+    workspaceLabel: string;
+    channelSummaries: Record<'dev' | 'nightly' | 'release', string>;
+    platformNames: Record<string, string>;
+    updatesLede: string;
+    supportTitle: string;
+    supportLede: string;
+    reportIssueHelp: string;
+    reportIssueOpen: string;
+    copyAction: string;
+    privacyLede: string;
     privacyLabel: string;
     privacyTitle: string;
     privacyPoints: readonly string[];
@@ -340,8 +352,16 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
       passwordSavedPlaceholder: '密码已保存;输入新密码以替换',
     },
     about: {
-      loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制诊断信息', pasteHint: '检查内容后,可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', packagedBuild: '正式版', nightlyBuild: 'Nightly', subtitle: '本地优先的 AI 助手 · 桌面端运行环境', privacyLabel: '隐私与安全', privacyTitle: '本地优先 · 隐私默认', privacyPoints: ['所有任务、设置、凭据和 Skill 指令文件都保留在本机工作区。', '模型密钥保存在本机凭据文件内;订阅账号令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权。', '每个任务都会在本机保 [...]
-      updatesTitle: '软件更新',
+      loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制诊断信息', pasteHint: '检查内容后,可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', packagedBuild: '正式版', nightlyBuild: 'Nightly', subtitle: '本地优先的 AI 助手 · 桌面端运行环境',
+      channelLabel: '渠道', runtimeLabel: '运行环境', workspaceLabel: '工作区',
+      channelSummaries: { dev: '不自动更新', nightly: '每晚构建,自动跟随更新', release: '打包安装,自动检查更新' },
+      platformNames: { darwin: 'macOS', win32: 'Windows', linux: 'Linux' },
+      updatesTitle: '软件更新', updatesLede: 'Maka 检查并安装新版本的渠道与状态。',
+      supportTitle: '支持与诊断', supportLede: '出问题时的三件事:取证、上报、查快捷键。',
+      copying: '复制中…', copyDiagnostics: '复制诊断信息', copyAction: '复制', copyHelp: '复制版本、平台、隐藏主目录后的工作区路径,以及近期脱敏的 Desktop 与 Runtime Host 日志;仅写入剪贴板,不会自动上传。',
+      reportIssueLabel: '报告问题', reportIssueHelp: '带上诊断信息去 GitHub Issues,回复更快。', reportIssueOpen: '打开',
+      keyboardShortcuts: '键盘快捷键', keyboardShortcutsHelp: 'Maka 支持的全部快捷键一览。', keyboardShortcutsOpen: '查看',
+      privacyLabel: '隐私承诺', privacyTitle: '隐私承诺', privacyLede: 'Maka 对本机数据与通信的三条承诺。', privacyPoints: ['任务、设置、凭据和 Skill 指令文件都留在本机;模型密钥保存在本机凭据文件内,订阅令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权;每个任务都会在本机保留消息、工具调用、权限决策与模式变更记录。'],
       checkForUpdates: '检查更新',
       checkingForUpdates: '检查中…',
       updateHelp: '后台也会定期检查;需要重启安装时侧栏会提示。',
@@ -394,8 +414,16 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
       passwordSavedPlaceholder: 'Password saved; enter a new password to replace it',
     },
     about: {
-      loadFailed: 'Could not load About information', loading: 'Loading About', unavailable: 'About information is unavailable', copied: 'Diagnostics copied', pasteHint: 'Review the content, then paste it into an issue report', copyFailed: 'Copy failed', clipboardUnavailable: 'The clipboard is unavailable or access was denied.', devBuild: 'Local development build', packagedBuild: 'Release build', nightlyBuild: 'Nightly', subtitle: 'A local-first AI assistant · Desktop runtime', privacyLa [...]
-      updatesTitle: 'Software updates',
+      loadFailed: 'Could not load About information', loading: 'Loading About', unavailable: 'About information is unavailable', copied: 'Diagnostics copied', pasteHint: 'Review the content, then paste it into an issue report', copyFailed: 'Copy failed', clipboardUnavailable: 'The clipboard is unavailable or access was denied.', devBuild: 'Local development build', packagedBuild: 'Release build', nightlyBuild: 'Nightly', subtitle: 'A local-first AI assistant · Desktop runtime',
+      channelLabel: 'Channel', runtimeLabel: 'Runtime', workspaceLabel: 'Workspace',
+      channelSummaries: { dev: 'no automatic updates', nightly: 'Nightly build, updates follow automatically', release: 'Packaged install, checks for updates automatically' },
+      platformNames: { darwin: 'macOS', win32: 'Windows', linux: 'Linux' },
+      updatesTitle: 'Software updates', updatesLede: 'How Maka checks for and installs new versions.',
+      supportTitle: 'Support & diagnostics', supportLede: 'Three things when something breaks: capture, report, look up shortcuts.',
+      copying: 'Copying…', copyDiagnostics: 'Copy diagnostics', copyAction: 'Copy', copyHelp: 'Copy version, platform, a home-redacted workspace path, and recent redacted Desktop and Runtime Host 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: 'Privacy commitments', privacyLede: 'Three commitments about your local data and network use.', 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 t [...]
       checkForUpdates: 'Check for updates',
       checkingForUpdates: 'Checking…',
       updateHelp: 'Maka also checks in the background. When a restart is required, the sidebar will prompt you.',
diff --git a/apps/desktop/src/renderer/locales/settings-shared-copy.ts b/apps/desktop/src/renderer/locales/settings-shared-copy.ts
index 834619160d..f414806ed0 100644
--- a/apps/desktop/src/renderer/locales/settings-shared-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-shared-copy.ts
@@ -64,8 +64,6 @@ export type SettingsSharedCopy = {
     memoryEntriesHelp: string;
     reviewSchedule: string;
     reviewScheduleHelp: string;
-    buildInfo: string;
-    reference: string;
   };
 };
 
@@ -108,8 +106,6 @@ const SETTINGS_SHARED_COPY_BY_LOCALE = {
       dataLocationHelp: '任务、设置、使用统计与凭据都以文件形式存放在本机的这个位置。',
       reviewSchedule: '回顾计划',
       reviewScheduleHelp: '每日回顾的生成时间与使用的模型。',
-      buildInfo: '版本信息',
-      reference: '参考',
     },
   },
   en: {
@@ -150,8 +146,6 @@ const SETTINGS_SHARED_COPY_BY_LOCALE = {
       dataLocationHelp: 'Tasks, settings, usage statistics, and credentials are stored as files in this location on your machine.',
       reviewSchedule: 'Review schedule',
       reviewScheduleHelp: 'When the daily review runs, and which model writes it.',
-      buildInfo: 'Build info',
-      reference: 'Reference',
     },
   },
 } satisfies UiCatalog<SettingsSharedCopy>;
diff --git a/apps/desktop/src/renderer/settings/about-channel-badge.ts b/apps/desktop/src/renderer/settings/about-channel-badge.ts
deleted file mode 100644
index 3d54463cb6..0000000000
--- a/apps/desktop/src/renderer/settings/about-channel-badge.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-import type { DesktopAppInfo } from '../../preload/bridge-contract.js';
-import type { SettingsPreferencesCopy } from '../locales/settings-preferences-copy.js';
-
-type AboutCopy = SettingsPreferencesCopy['about'];
-
-/** The channel pill's Astryx Badge variant, narrow enough to assign directly. */
-export type AboutChannelBadgeVariant = 'neutral' | 'blue' | 'orange';
-
-export interface AboutChannelBadge {
-  readonly label: string;
-  readonly variant: AboutChannelBadgeVariant;
-}
-
-/**
- * What the version pill next to "Maka" actually says, pure for unit tests.
- *
- * Build mode and release channel answer different questions: `buildMode` says
- * how this binary was produced (a checkout vs a packaged install), while
- * `updateChannel` says which release feed it follows. A packaged nightly is a
- * real install but NOT a release, so the old "packaged → 正式版" mapping lied
- * to exactly the users running nightly builds. Dev mode keeps the commit in
- * the label and drops to `neutral`: a checkout is not a release artifact
- * either, so it must not wear the release blue.
- */
-export function aboutChannelBadge(
-  info: Pick<DesktopAppInfo, 'buildMode' | 'buildCommit' | 'updateChannel'>,
-  copy: AboutCopy,
-): AboutChannelBadge {
-  if (info.buildMode === 'dev') {
-    return {
-      label: info.buildCommit ? `${copy.devBuild} · ${info.buildCommit}` : copy.devBuild,
-      variant: 'neutral',
-    };
-  }
-  if (info.updateChannel === 'nightly') {
-    return { label: copy.nightlyBuild, variant: 'orange' };
-  }
-  return { label: copy.packagedBuild, variant: 'blue' };
-}
\ No newline at end of file
diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx
index 8abc5e2899..45cc3c1833 100644
--- a/apps/desktop/src/renderer/settings/about-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx
@@ -18,7 +18,20 @@
  */
 
 import { useEffect, useId, useState, type ReactNode } from 'react';
-import { Badge, Link, List, ListItem } from '@astryxdesign/core';
+import {
+  Badge,
+  Divider,
+  Grid,
+  Heading,
+  HStack,
+  Link,
+  List,
+  ListItem,
+  MetadataList,
+  MetadataListItem,
+  Text,
+  VStack,
+} from '@astryxdesign/core';
 import { Kbd } from '@astryxdesign/core/Kbd';
 import { Sparkles } from '@maka/ui/icons';
 import {
@@ -30,15 +43,12 @@ import {
   useUiLocale,
 } from '@maka/ui';
 import type { AppUpdateStatus } from '../../preload/bridge-contract.js';
-import { SettingsActions, SettingsPage, SettingsSection } from './settings-section.js';
-import { SettingRow } from './settings-rows.js';
+import { SettingsPage } 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 { aboutUpdateStatusDetail } from './about-update-status.js';
-import { aboutChannelBadge } from './about-channel-badge.js';
+import { aboutChannelBadge, aboutUpdateStatusDetail } from './about-update-status.js';
 import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy.js';
-import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js';
 import {
   defaultRuntimeHostDiagnosticTarget,
   runOnDefaultRuntimeHost,
@@ -51,7 +61,6 @@ const ISSUE_TRACKER_URL = 'https://github.com/apache/maka/issues';
 export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
   const locale = useUiLocale();
   const copy = getSettingsPreferencesCopy(locale).about;
-  const sharedCopy = getSettingsSharedCopy(locale);
   const [info, setInfo] = useState<AppInfo | null>(null);
   const [infoError, setInfoError] = useState<string | null>(null);
   const [copyingDiagnostics, setCopyingDiagnostics] = useState(false);
@@ -63,6 +72,9 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
   const toast = useToast();
   const diagnosticsHelpId = useId();
   const updateHelpId = useId();
+  const updatesHeadingId = useId();
+  const supportHeadingId = useId();
+  const privacyHeadingId = useId();
 
   useEffect(() => {
     let cancelled = false;
@@ -74,16 +86,17 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
         }
       })
       .catch((error) => {
-        if (cancelled) return;
-        const message = settingsActionErrorMessage(error, locale);
-        setInfoError(message);
-        toast.error(
-          copy.loadFailed,
-          message,
-          undefined,
-          defaultRuntimeHostDiagnosticTarget(error),
-        );
-    });
+        if (!cancelled) {
+          const message = settingsActionErrorMessage(error, locale);
+          setInfoError(message);
+          toast.error(
+            copy.loadFailed,
+            message,
+            undefined,
+            defaultRuntimeHostDiagnosticTarget(error),
+          );
+        }
+      });
     return () => {
       cancelled = true;
     };
@@ -163,75 +176,86 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
     );
   } else {
     const channelBadge = aboutChannelBadge(info, copy);
+    const channelKey = info.buildMode === 'dev' ? 'dev' : info.updateChannel;
+    const isDevBuild = info.buildMode === 'dev';
+    // The contract hands us `homePath` for exactly this collapse.
+    const workspaceDisplay = info.workspacePath.startsWith(info.homePath)
+      ? `~${info.workspacePath.slice(info.homePath.length)}`
+      : info.workspacePath;
     aboutContent = (
       <>
-        <PageHeader
-          as_wrapper="div"
-          className="settingsAboutHero"
-          as="h2"
-          icon={<Sparkles size={30} /> /* 64% of the 48px plate, matching .providerLogo's fill */}
-          iconClassName="settingsAboutLogo"
-          headingRowClassName="settingsAboutHeading"
-          title="Maka"
-          badge={
-            <>
-              <Badge variant="neutral" label={`v${info.appVersion}`} />
-              <Badge variant={channelBadge.variant} label={channelBadge.label} />
-            </>
-          }
-          subtitle={copy.subtitle}
-          subtitleClassName="settingsAboutTagline"
-        />
-        {/* Detail audit: the five privacy commitments rendered inside an info
-            Banner — five lines of bold status-blue body copy, the exact blue
-            flood DESIGN.md's Signal-Not-Texture rule forbids. They are ordinary
-            statements, so they read as a quiet marker list in a labeled group. */}
-        <SettingsSection variant="bare" title={copy.privacyTitle}>
-          <List aria-label={copy.privacyLabel} density="compact" listStyle="disc">
-            {/* Fragment-wrapped: ListItem single-line-truncates STRING labels,
-                and a privacy commitment must wrap, not ellipsize. */}
-            {copy.privacyPoints.map((point) => <ListItem key={point} label={<>{point}</>} />)}
-          </List>
-        </SettingsSection>
-        {/* The keyboard sheet's home. It used to be reachable only from the
-            titlebar's `…` drawer and from two shortcuts — which made the panel
-            that lists the shortcuts openable only by shortcut. It is reference
-            material about the app, so it belongs on 关于, and this is the entry
-            a mouse can find. */}
-        {props.onOpenKeyboardHelp && (
-          <SettingsSection title={sharedCopy.groups.reference}>
-            <SettingRow
-              title={copy.keyboardShortcuts}
-              detail={copy.keyboardShortcutsHelp}
-              action={(
-                <Button variant="ghost" size="sm" onClick={props.onOpenKeyboardHelp} label={copy.keyboardShortcutsOpen} />
-              )}
-            />
-          </SettingsSection>
-        )}
-        <SettingsSection title={copy.updatesTitle}>
-          <SettingRow
-            title={copy.checkForUpdates}
-            detail={aboutUpdateStatusDetail(updateStatus, copy, {
-              isDevBuild: info.buildMode === 'dev',
-            })}
-            action={(
-              <Button
-                variant="secondary"
-                size="sm"
-                isDisabled={checkingUpdate || info.buildMode === 'dev'}
-                aria-describedby={updateHelpId}
-                onClick={() => void checkForUpdates()}
-                label={checkingUpdate || updateStatus?.state === 'checking'
-                  ? copy.checkingForUpdates
-                  : copy.checkForUpdates}
-              />
-            )}
+        <VStack gap={5}>
+          <PageHeader
+            as_wrapper="div"
+            className="settingsAboutHero"
+            as="h2"
+            icon={<Sparkles size={30} /> /* 64% of the 48px plate, matching .providerLogo's fill */}
+            iconClassName="settingsAboutLogo"
+            headingRowClassName="settingsAboutHeading"
+            title="Maka"
+            badge={
+              <>
+                <Badge variant="neutral" label={`v${info.appVersion}`} />
+                <Badge variant={channelBadge.variant} label={channelBadge.label} />
+              </>
+            }
+            subtitle={copy.subtitle}
+            subtitleClassName="settingsAboutTagline"
           />
-          <p id={updateHelpId}>
-            {info.buildMode === 'dev' ? copy.updateDevBuildHelp : copy.updateHelp}
-          </p>
-        </SettingsSection>
+          {/* The archive readout: what channel this is, what it runs on, and
+              where the data lives. Astryx's label → value primitive, the same
+              construction the MCP detail panel uses — no hairlines of ours. */}
+          <MetadataList columns="single" label={{ position: 'start', width: 88 }}>
+            <MetadataListItem label={copy.channelLabel}>
+              <Text type="body">
+                {channelBadge.channelName} · {copy.channelSummaries[channelKey]}
+              </Text>
+            </MetadataListItem>
+            <MetadataListItem label={copy.runtimeLabel}>
+              <Text type="body">
+                {copy.platformNames[info.platform] ?? info.platform} · {info.arch} · Electron {info.electronVersion}
+              </Text>
+            </MetadataListItem>
+            <MetadataListItem label={copy.workspaceLabel}>
+              <Text type="body"><code>{workspaceDisplay}</code></Text>
+            </MetadataListItem>
+          </MetadataList>
+        </VStack>
+        <Divider />
+        <section aria-labelledby={updatesHeadingId}>
+          <Grid columns={{ minWidth: 320, max: 2, repeat: 'fit' }} gap={10}>
+            <VStack gap={1}>
+              <Heading level={3} id={updatesHeadingId}>{copy.updatesTitle}</Heading>
+              <Text type="supporting" size="sm" color="secondary">{copy.updatesLede}</Text>
+            </VStack>
+            <VStack gap={2}>
+              {/* A dev build's status detail IS the explanation (本地开发版不检查
+                  GitHub 发布更新…), so the background-check paragraph must not
+                  repeat it — the old page printed that sentence twice. */}
+              <Text type="body" id={isDevBuild ? updateHelpId : undefined}>
+                {aboutUpdateStatusDetail(updateStatus, copy, { isDevBuild })}
+              </Text>
+              {!isDevBuild && (
+                <Text type="supporting" size="sm" color="secondary" id={updateHelpId}>
+                  {copy.updateHelp}
+                </Text>
+              )}
+              <HStack gap={2}>
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  isDisabled={checkingUpdate || isDevBuild}
+                  aria-describedby={updateHelpId}
+                  onClick={() => void checkForUpdates()}
+                  label={checkingUpdate || updateStatus?.state === 'checking'
+                    ? copy.checkingForUpdates
+                    : copy.checkForUpdates}
+                />
+              </HStack>
+            </VStack>
+          </Grid>
+        </section>
+        <Divider />
       </>
     );
   }
@@ -239,22 +263,80 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
   return (
     <SettingsPage>
       {aboutContent}
-      <SettingsSection title={sharedCopy.groups.buildInfo}>
-        <SettingsActions>
-          <Button
-            variant="primary"
-            isDisabled={copyingDiagnostics}
-            aria-describedby={diagnosticsHelpId}
-            onClick={() => void copyDiagnostics()}
-            label={copyingDiagnostics ? copy.copying : copy.copyDiagnostics}
-          />
-          <Kbd keys="mod+shift+d" />
-          <Link href={ISSUE_TRACKER_URL} target="_blank" rel="noreferrer noopener">
-            {copy.reportIssueLabel}
-          </Link>
-          <p id={diagnosticsHelpId}>{copy.copyHelp}</p>
-        </SettingsActions>
-      </SettingsSection>
+      {/* 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. */}
+      <section aria-labelledby={supportHeadingId}>
+        <Grid columns={{ minWidth: 320, max: 2, repeat: 'fit' }} gap={10}>
+          <VStack gap={1}>
+            <Heading level={3} id={supportHeadingId}>{copy.supportTitle}</Heading>
+            <Text type="supporting" size="sm" color="secondary">{copy.supportLede}</Text>
+          </VStack>
+          <VStack gap={5}>
+            <VStack gap={1} align="start">
+              <Text type="label">{copy.copyDiagnostics}</Text>
+              <Text type="supporting" size="sm" color="secondary" id={diagnosticsHelpId}>
+                {copy.copyHelp}
+              </Text>
+              <HStack gap={2}>
+                <Button
+                  variant="primary"
+                  size="sm"
+                  isDisabled={copyingDiagnostics}
+                  aria-describedby={diagnosticsHelpId}
+                  onClick={() => void copyDiagnostics()}
+                  label={copyingDiagnostics ? copy.copying : copy.copyAction}
+                />
+                <Kbd keys="mod+shift+d" />
+              </HStack>
+            </VStack>
+            <VStack gap={1} align="start">
+              <Text type="label">{copy.reportIssueLabel}</Text>
+              <Text type="supporting" size="sm" color="secondary">{copy.reportIssueHelp}</Text>
+              <HStack gap={2}>
+                <Link href={ISSUE_TRACKER_URL} target="_blank" rel="noreferrer noopener">
+                  {copy.reportIssueOpen}
+                </Link>
+              </HStack>
+            </VStack>
+            {props.onOpenKeyboardHelp && (
+              <VStack gap={1} align="start">
+                <Text type="label">{copy.keyboardShortcuts}</Text>
+                <Text type="supporting" size="sm" color="secondary">{copy.keyboardShortcutsHelp}</Text>
+                <HStack gap={2}>
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    onClick={props.onOpenKeyboardHelp}
+                    label={copy.keyboardShortcutsOpen}
+                  />
+                </HStack>
+              </VStack>
+            )}
+          </VStack>
+        </Grid>
+      </section>
+      {info && (
+        <>
+          <Divider />
+          <section aria-labelledby={privacyHeadingId}>
+            <Grid columns={{ minWidth: 320, max: 2, repeat: 'fit' }} gap={10}>
+              <VStack gap={1}>
+                <Heading level={3} id={privacyHeadingId}>{copy.privacyTitle}</Heading>
+                <Text type="supporting" size="sm" color="secondary">{copy.privacyLede}</Text>
+              </VStack>
+              <List aria-label={copy.privacyLabel} density="compact" listStyle="disc">
+                {/* Fragment-wrapped: ListItem single-line-truncates STRING labels,
+                    and a privacy commitment must wrap, not ellipsize. */}
+                {copy.privacyPoints.map((point) => <ListItem key={point} label={<>{point}</>} />)}
+              </List>
+            </Grid>
+          </section>
+        </>
+      )}
     </SettingsPage>
   );
-}
+}
\ No newline at end of file
diff --git a/apps/desktop/src/renderer/settings/about-update-status.ts b/apps/desktop/src/renderer/settings/about-update-status.ts
index ca550af56e..1f02f2a948 100644
--- a/apps/desktop/src/renderer/settings/about-update-status.ts
+++ b/apps/desktop/src/renderer/settings/about-update-status.ts
@@ -17,11 +17,50 @@
  * under the License.
  */
 
-import type { AppUpdateStatus } from '../../preload/bridge-contract.js';
+import type { AppUpdateStatus, DesktopAppInfo } from '../../preload/bridge-contract.js';
 import type { SettingsPreferencesCopy } from '../locales/settings-preferences-copy.js';
 
 type AboutCopy = SettingsPreferencesCopy['about'];
 
+/** The channel pill's Astryx Badge variant, narrow enough to assign directly. */
+export type AboutChannelBadgeVariant = 'neutral' | 'blue' | 'orange';
+
+export interface AboutChannelBadge {
+  readonly label: string;
+  readonly variant: AboutChannelBadgeVariant;
+  /** The bare channel name without the dev commit suffix — for readouts that
+   *  already sit next to the version pill and don't need it repeated. */
+  readonly channelName: string;
+}
+
+/**
+ * What the version pill next to "Maka" actually says, pure for unit tests.
+ *
+ * Build mode and release channel answer different questions: `buildMode` says
+ * how this binary was produced (a checkout vs a packaged install), while
+ * `updateChannel` says which release feed it follows. A packaged nightly is a
+ * real install but NOT a release, so the old "packaged → 正式版" mapping lied
+ * to exactly the users running nightly builds. Dev mode keeps the commit in
+ * the label and drops to `neutral`: a checkout is not a release artifact
+ * either, so it must not wear the release blue.
+ */
+export function aboutChannelBadge(
+  info: Pick<DesktopAppInfo, 'buildMode' | 'buildCommit' | 'updateChannel'>,
+  copy: AboutCopy,
+): AboutChannelBadge {
+  if (info.buildMode === 'dev') {
+    return {
+      label: info.buildCommit ? `${copy.devBuild} · ${info.buildCommit}` : copy.devBuild,
+      variant: 'neutral',
+      channelName: copy.devBuild,
+    };
+  }
+  if (info.updateChannel === 'nightly') {
+    return { label: copy.nightlyBuild, variant: 'orange', channelName: copy.nightlyBuild };
+  }
+  return { label: copy.packagedBuild, variant: 'blue', channelName: copy.packagedBuild };
+}
+
 /** Map updater state to About-page detail copy (pure for unit tests). */
 export function aboutUpdateStatusDetail(
   status: AppUpdateStatus | null,


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


(maka) 03/04: feat(desktop): name the update channel in the diagnostics report

Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch feat/about-page-hierarchy
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 9b75da3a5ff34e57f4df81a58075b3f349a058a8
Author: AstroHan <le...@outlook.com>
AuthorDate: Wed Sep 2 22:51:36 2026 +0800

    feat(desktop): name the update channel in the diagnostics report
    
    The diagnostics report printed `Build: packaged @ <sha>`, which cannot tell a nightly install from a release one — the two channels update from different feeds and reproduce different bugs. Read the channel from the packaged manifest through a total helper that degrades to `unknown` instead of throwing, because the report must still copy when the manifest itself is what broke.
---
 .../__tests__/main-process-diagnostics.test.ts     |  21 +++
 .../__tests__/native-diagnostic-dialog.test.ts     |   1 +
 apps/desktop/src/main/app-update-attestation.ts    |  27 ++-
 apps/desktop/src/main/main-process-diagnostics.ts  |  11 ++
 apps/desktop/src/main/main.ts                      |   9 +
 apps/desktop/src/main/runtime-host-boot.ts         |   5 +
 packages/ui/src/primitives/page-header.tsx         | 191 ---------------------
 7 files changed, 72 insertions(+), 193 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts b/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts
index ec6b42eba7..b96c0114f4 100644
--- a/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts
+++ b/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts
@@ -32,11 +32,13 @@ import {
   MAIN_PROCESS_DIAGNOSTIC_LOG_MAX_BYTES,
   mainProcessLogBuffer,
   parseDesktopDiagnosticInput,
+  type DesktopDiagnosticChannel,
 } from '../main-process-diagnostics.js';
 
 const environment = {
   appVersion: '0.1.8',
   buildMode: 'dev' as const,
+  updateChannel: 'dev' as const,
   buildCommit: 'a'.repeat(40),
   electronVersion: '38.0.0',
   nodeVersion: '22.0.0',
@@ -107,6 +109,25 @@ test('formats one redacted Desktop and Runtime Host diagnostic report', () => {
   assert.doesNotMatch(report, /sk-secretvalue123|\/home\/tester/);
 });
 
+test('names the update channel, which buildMode alone cannot distinguish', () => {
+  const reportFor = (channel: DesktopDiagnosticChannel, buildMode: 'dev' | 'packaged') =>
+    formatDesktopDiagnosticReport(
+      { surface: 'manual', hostTarget: 'default' },
+      { ...environment, buildMode, updateChannel: channel },
+      [],
+      { ok: false, error: 'not started' },
+      undefined,
+      new Date('2026-08-09T00:00:00Z'),
+    );
+
+  // Both packaged installs report `Build: packaged`; only the channel line
+  // says which feed and which attestation signer they trust.
+  assert.match(reportFor('nightly', 'packaged'), /^Channel: nightly$/mu);
+  assert.match(reportFor('release', 'packaged'), /^Channel: release$/mu);
+  // A checkout follows no feed, so it is neither of them.
+  assert.match(reportFor('dev', 'dev'), /^Channel: dev$/mu);
+});
+
 test('bounds renderer diagnostic text and rejects unknown fields', () => {
   const input = parseDesktopDiagnosticInput({
     surface: 'toast',
diff --git a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts
index fd5a621e6c..954f0d89b6 100644
--- a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts
+++ b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts
@@ -36,6 +36,7 @@ import {
 const diagnosticEnvironment = () => ({
   appVersion: '0.1.8',
   buildMode: 'packaged' as const,
+  updateChannel: 'release' as const,
   buildCommit: null,
   electronVersion: '38.0.0',
   nodeVersion: '22.0.0',
diff --git a/apps/desktop/src/main/app-update-attestation.ts b/apps/desktop/src/main/app-update-attestation.ts
index bc8a4775ef..bc2d01478f 100644
--- a/apps/desktop/src/main/app-update-attestation.ts
+++ b/apps/desktop/src/main/app-update-attestation.ts
@@ -21,8 +21,8 @@ import { bundleFromJSON, type Bundle } from '@sigstore/bundle';
 import { getTrustedRoot } from '@sigstore/tuf';
 import { toSignedEntity, toTrustMaterial, Verifier } from '@sigstore/verify';
 import { createHash } from 'node:crypto';
-import { createReadStream } from 'node:fs';
-import { basename } from 'node:path';
+import { createReadStream, readFileSync } from 'node:fs';
+import { basename, join } from 'node:path';
 
 const PRODUCT_REPOSITORY = 'apache/maka';
 const PRODUCT_RELEASE_WORKFLOW = '.github/workflows/release-cli-finalize.yml';
@@ -80,6 +80,29 @@ export function desktopUpdateChannelFromManifest(manifest: unknown): DesktopUpda
   return channel;
 }
 
+/**
+ * The channel a diagnostic report names for this binary.
+ *
+ * Total by construction, unlike `desktopUpdateChannelFromManifest`: a report
+ * must still copy when the manifest is the very thing that is broken, and the
+ * report saying `unknown` is more useful than the copy failing. A checkout
+ * follows no feed at all, so it reports `dev` rather than the updater's
+ * `release` placeholder.
+ */
+export function desktopDiagnosticUpdateChannel(input: {
+  readonly isPackaged: boolean;
+  readonly appPath: string;
+}): DesktopUpdateChannel | 'dev' | 'unknown' {
+  if (!input.isPackaged) return 'dev';
+  try {
+    return desktopUpdateChannelFromManifest(
+      JSON.parse(readFileSync(join(input.appPath, 'package.json'), 'utf8')),
+    );
+  } catch {
+    return 'unknown';
+  }
+}
+
 function productWorkflowSigner(channel: DesktopUpdateChannel): RegExp {
   const workflow = channel === 'nightly' ? PRODUCT_NIGHTLY_WORKFLOW : PRODUCT_RELEASE_WORKFLOW;
   return new RegExp(
diff --git a/apps/desktop/src/main/main-process-diagnostics.ts b/apps/desktop/src/main/main-process-diagnostics.ts
index dba428ab43..a5b39dccdd 100644
--- a/apps/desktop/src/main/main-process-diagnostics.ts
+++ b/apps/desktop/src/main/main-process-diagnostics.ts
@@ -44,9 +44,18 @@ const INPUT_TRUNCATION_MARKER = '\n<diagnostic input truncated>';
 const EXECUTION_DIAGNOSTIC_TIMEOUT_MS = 2_000;
 export const MAIN_PROCESS_DIAGNOSTIC_LOG_MAX_BYTES = 256 * 1024;
 
+/**
+ * The release feed a build follows, as a report names it. `buildMode` alone
+ * cannot say this: every packaged install reports `packaged`, so a nightly and
+ * a release looked identical in a report while following different feeds and
+ * different attestation signers. `dev` is not a feed — a checkout follows none.
+ */
+export type DesktopDiagnosticChannel = 'release' | 'nightly' | 'dev' | 'unknown';
+
 export interface DesktopDiagnosticEnvironment {
   readonly appVersion: string;
   readonly buildMode: 'dev' | 'packaged';
+  readonly updateChannel: DesktopDiagnosticChannel;
   readonly buildCommit: string | null;
   readonly electronVersion: string;
   readonly nodeVersion: string;
@@ -63,6 +72,7 @@ export interface DesktopDiagnosticEnvironment {
 export interface DesktopDiagnosticEnvironmentSource {
   readonly appVersion: string;
   readonly buildMode: 'dev' | 'packaged';
+  readonly updateChannel: DesktopDiagnosticChannel;
   readonly buildCommit: string | null;
   readonly locale: string;
   readonly workspacePath: string;
@@ -431,6 +441,7 @@ export function formatDesktopDiagnosticReport(
       'Environment',
       `Maka: ${environment.appVersion}`,
       `Build: ${environment.buildMode}${environment.buildCommit ? ` @ ${environment.buildCommit.slice(0, 12)}` : ''}`,
+      `Channel: ${environment.updateChannel}`,
       `Electron: ${environment.electronVersion}`,
       `Chrome: ${environment.chromeVersion}`,
       `Node: ${environment.nodeVersion}`,
diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts
index 8cea0b9d1d..16133b4515 100644
--- a/apps/desktop/src/main/main.ts
+++ b/apps/desktop/src/main/main.ts
@@ -27,6 +27,7 @@ import { app, clipboard, dialog, ipcMain } from 'electron';
 import { join } from 'node:path';
 import { resolveBuildInfo } from './build-info.js';
 import { resolveUpdateTestUserDataDirectory } from './app-update-test-context.js';
+import { desktopDiagnosticUpdateChannel } from './app-update-attestation.js';
 import {
   captureDesktopDiagnosticEnvironment,
   copyDesktopDiagnosticReport,
@@ -176,6 +177,10 @@ if (!app.requestSingleInstanceLock()) {
       captureDesktopDiagnosticEnvironment({
         appVersion: app.getVersion(),
         buildMode: buildInfo.mode,
+        updateChannel: desktopDiagnosticUpdateChannel({
+          isPackaged: app.isPackaged,
+          appPath: app.getAppPath(),
+        }),
         buildCommit: buildInfo.commit,
         locale: app.getLocale(),
         workspacePath: join(app.getPath('userData'), 'workspaces', 'default'),
@@ -212,6 +217,10 @@ if (!app.requestSingleInstanceLock()) {
               captureDesktopDiagnosticEnvironment({
                 appVersion: app.getVersion(),
                 buildMode: buildInfo.mode,
+                updateChannel: desktopDiagnosticUpdateChannel({
+                  isPackaged: app.isPackaged,
+                  appPath: app.getAppPath(),
+                }),
                 buildCommit: buildInfo.commit,
                 locale: app.getLocale(),
                 workspacePath: join(app.getPath('userData'), 'workspaces', 'default'),
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 75e05259fb..e65193172d 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -70,6 +70,7 @@ import { createMcpOAuthController } from "./mcp-oauth-controller.js";
 import { registerAppClientIpc, registerAppIpc } from "./app-ipc-main.js";
 import { createAppQuitCoordinator } from "./app-quit-coordinator.js";
 import {
+  desktopDiagnosticUpdateChannel,
   desktopUpdateChannelFromManifest,
   verifyDownloadedUpdateAttestation,
 } from "./app-update-attestation.js";
@@ -326,6 +327,10 @@ const desktopDiagnostics: DesktopDiagnosticsDeps = {
     captureDesktopDiagnosticEnvironment({
       appVersion: app.getVersion(),
       buildMode: buildInfo.mode,
+      updateChannel: desktopDiagnosticUpdateChannel({
+        isPackaged: app.isPackaged,
+        appPath: app.getAppPath(),
+      }),
       buildCommit: buildInfo.commit,
       locale: app.getLocale(),
       workspacePath: workspaceRoot,
diff --git a/packages/ui/src/primitives/page-header.tsx b/packages/ui/src/primitives/page-header.tsx
deleted file mode 100644
index 28b7b77e35..0000000000
--- a/packages/ui/src/primitives/page-header.tsx
+++ /dev/null
@@ -1,191 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-// packages/ui/src/primitives/page-header.tsx
-//
-// PageHeader — the shared header shell for the SETTINGS intro dialect:
-//
-//   SETTINGS intros (as='h3', or as='h2' for the About hero): the smaller
-//      Permission / Health / About page intro cards — a body-tier
-//      semibold title, a lede, and a trailing quieter META cluster
-//      (RelativeTime + refresh Button) or a leading feature ICON + trailing
-//      BADGE chip. Previously `.settingsPermissionIntro`,
-//      `.settingsHealthIntro`, `.settingsAboutHero`.
-//
-//   The MODULE-page dialect this primitive also used to serve (the
-//   `.maka-module-main-header` hero) is retired: 技能 / MCP / 定时任务 /
-//   每日回顾 moved onto the ModulePage primitive, whose Astryx Layout header
-//   owns title + meta + actions.
-//
-// Layout & typography strategy: the shell is styled with portable semantic
-// utilities, but every call site KEEPS its existing wrapper class (passed via
-// `className`) so the wrapper CSS — which already owns the surface chrome
-// (card border/background/radius, flex vs grid layout, gap, mobile
-// breakpoints) and the per-slot typography (`.settingsPermissionIntro h3`,
-// `.settingsAboutHero p`, …) — keeps governing the visuals unchanged.
-// The primitive only converges the STRUCTURE (slot order, title/subtitle/
-// eyebrow/badge/icon/actions/meta arrangement). Slots expose `data-slot`
-// hooks so contracts that used to pin the old `h2`/`p` direct children can
-// re-pin the primitive's slots where DOM structure genuinely moved.
-
-import type { ReactNode } from 'react';
-import { cn } from '../utils.js';
-
-export interface PageHeaderProps {
-  /** Title text. Rendered as an <h2> (module scale) or <h3> (settings scale). */
-  title: ReactNode;
-  /** Optional lede line under the title (muted). */
-  subtitle?: ReactNode;
-  /**
-   * Optional small caps/eyebrow line ABOVE the title (e.g. a section kicker).
-   * Rendered muted+semibold before the title row.
-   */
-  eyebrow?: ReactNode;
-  /** Optional leading glyph, rendered in a framed icon box left of the content. */
-  icon?: ReactNode;
-  /**
-   * Optional marker rendered inline right AFTER the title (About
-   * version/channel pills). Accepts one node or a fragment.
-   */
-  badge?: ReactNode;
-  /**
-   * Title heading level + scale. 'h2' = the About hero scale, 'h3' = the
-   * settings intro scale. Defaults to 'h2'. The exact font-size/weight is
-   * left to the wrapper CSS (`.settingsAboutHero h2`,
-   * `.settingsPermissionIntro h3`, …); this only picks the semantic tag.
-   */
-  as?: 'h2' | 'h3';
-  /** Id applied to the title element (aria-labelledby targets). */
-  titleId?: string;
-  /**
-   * Right-aligned action cluster (buttons, search box). The cluster's own
-   * positioning class (e.g. `maka-module-main-actions`, `maka-scheduled-task-top-actions`)
-   * is passed as a child by the call site; the shell only slots it to the end.
-   */
-  actions?: ReactNode;
-  /**
-   * Trailing quieter cluster — the settings meta stack (RelativeTime +
-   * refresh Button + optional read-only badge). Like `actions`, the cluster's
-   * own class (`settingsPermissionMeta`, `settingsHealthMeta`) rides on the
-   * child; the shell only slots it.
-   */
-  meta?: ReactNode;
-  /** Wrapper class — the existing call-site hook (kept so CSS + contracts stay pinned). */
-  className?: string;
-  /**
-   * Class for the title+subtitle content column (e.g. `maka-scheduled-task-heading`).
-   * Lets a call site keep a wrapper that its CSS targets as the heading group.
-   */
-  contentClassName?: string;
-  /** Class for the leading icon box (e.g. `settingsAboutLogo`). */
-  iconClassName?: string;
-  /** Class for the inline title+badge row (e.g. `settingsAboutHeading`). */
-  headingRowClassName?: string;
-  /** Class for the subtitle line (e.g. `settingsAboutTagline`), when a call site's CSS targets it. */
-  subtitleClassName?: string;
-  /** Render the wrapper as a <header> (default) or a <div>. */
-  as_wrapper?: 'header' | 'div';
-}
-
-export function PageHeader({
-  title,
-  subtitle,
-  eyebrow,
-  icon,
-  badge,
-  as = 'h2',
-  titleId,
-  actions,
-  meta,
-  className,
-  contentClassName,
-  iconClassName,
-  headingRowClassName,
-  subtitleClassName,
-  as_wrapper = 'header',
-}: PageHeaderProps): ReactNode {
-  const Title = as;
-  const Wrapper = as_wrapper;
-
-  // The title row: the heading, then any inline badge marker. When neither a
-  // badge nor a headingRow class is supplied we render the bare heading, so
-  // simple call sites (skills/plan/permission/health) keep a plain `h2`/`h3`
-  // as the direct descendant their CSS expects.
-  const heading = (
-    <Title
-      id={titleId}
-      data-slot="page-header-title"
-      // No typography utilities here — the wrapper CSS owns h2/h3 sizing.
-      className="maka-page-header-title"
-    >
-      {title}
-    </Title>
-  );
-  const titleRow =
-    badge != null || headingRowClassName ? (
-      <div
-        data-slot="page-header-heading-row"
-        className={cn('maka-page-header-heading-row', headingRowClassName)}
-      >
-        {heading}
-        {badge != null ? badge : null}
-      </div>
-    ) : (
-      heading
-    );
-
-  const content = (
-    <div
-      data-slot="page-header-content"
-      className={cn('maka-page-header-content', contentClassName)}
-    >
-      {eyebrow != null ? (
-        <p data-slot="page-header-eyebrow" className="maka-page-header-eyebrow">
-          {eyebrow}
-        </p>
-      ) : null}
-      {titleRow}
-      {subtitle != null ? (
-        <p
-          data-slot="page-header-subtitle"
-          className={cn('maka-page-header-subtitle', subtitleClassName)}
-        >
-          {subtitle}
-        </p>
-      ) : null}
-    </div>
-  );
-
-  return (
-    <Wrapper data-slot="page-header" className={className}>
-      {icon != null ? (
-        <span
-          data-slot="page-header-icon"
-          aria-hidden="true"
-          className={iconClassName}
-        >
-          {icon}
-        </span>
-      ) : null}
-      {content}
-      {actions != null ? actions : null}
-      {meta != null ? meta : null}
-    </Wrapper>
-  );
-}


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