Эх сурвалжийг харах

feat(slider): 字体大小滑块按参考稿重做视觉

- 轨道 3px,已填充深色、未填充浅灰,对比清晰不厚重
- 手柄改为小号胶囊(8×20),浅芯 + 深边描边,明暗两套主题都可辨识
- 6 个等距刻度短线,仅第 1/2/6 个带文字(小 / 默认 / 大)
- 刻度线紧贴轨道下沿,读起来像从轨道长出的标尺
- 刻度与手柄共用同一百分比坐标系,容器左右留内边距,首尾文字不被裁
- 移除设置页外部那套 legend,避免与滑块自带标签重复且对不齐
- 新增 ui-slider 的 label 输入,用于无障碍命名(原生 range 无可见文案时读屏只能念「滑块」)
- 新增 prefers-reduced-motion 支持

验证:tsc 0 错误;单测 1074/1074;构建通过;verify:ui 12/12;已用 Chrome 实际渲染截图核对效果

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 өдөр өмнө
parent
commit
a164b51e99

+ 24 - 18
src/app/desktop/pages/mine/mine.ts

@@ -81,6 +81,10 @@ import { SessionService } from '../../../core/session.service';
               字体大小(2026-10-30 改版):分段控件 → **滑块 + 小/默认/大三段刻度**,
               与参考设置面板一致;作用范围也从"会话正文"升级为**全局字号**。
             -->
+            <!--
+              字体大小(2026-11-01 视觉重做):滑块自带「小 / 默认 / 大」刻度标签,
+              不再在外部另起一行 legend —— 两套标签会让刻度与文字对不齐。
+            -->
             <div class="setting-block">
               <div class="setting-copy">
                 <h4>字体大小</h4>
@@ -88,6 +92,7 @@ import { SessionService } from '../../../core/session.service';
               </div>
               <div class="font-scale">
                 <ui-slider
+                  label="字体大小"
                   [min]="fontMin"
                   [max]="fontMax"
                   [step]="1"
@@ -95,9 +100,6 @@ import { SessionService } from '../../../core/session.service';
                   [value]="chatFont.size()"
                   (valueChange)="setChatFont($event)"
                 />
-                <div class="font-legend" aria-hidden="true">
-                  <span>小</span><span>默认</span><span>大</span>
-                </div>
               </div>
             </div>
 
@@ -228,16 +230,9 @@ import { SessionService } from '../../../core/session.service';
       }
 
       /* 字体大小滑块区:滑块在上、小/默认/大 说明在下 */
+      /* 滑块的刻度与标签由 ui-slider 自己渲染(2026-11-01) */
       .font-scale {
-        display: grid;
-        gap: var(--sp-1);
-        margin-top: var(--sp-2);
-      }
-      .font-legend {
-        display: flex;
-        justify-content: space-between;
-        font-size: var(--fs-11);
-        color: var(--muted);
+        margin-top: var(--sp-3);
       }
 
       .settings-layout {
@@ -532,12 +527,23 @@ export class DkMinePage {
   readonly chatFont = inject(ChatFontService);
   readonly fontMin = CHAT_FONT_SIZES[0];
   readonly fontMax = CHAT_FONT_SIZES[CHAT_FONT_SIZES.length - 1];
-  /** 滑块刻度:只标三处(小 / 默认 / 大),标满会显得杂乱 */
-  readonly fontMarks = [
-    { value: CHAT_FONT_SIZES[0], label: '' },
-    { value: DEFAULT_CHAT_FONT, label: '' },
-    { value: CHAT_FONT_SIZES[CHAT_FONT_SIZES.length - 1], label: '' },
-  ];
+  /**
+   * 滑块刻度(2026-11-01 按参考稿定稿)。
+   *
+   * 6 个刻度、**每个都画短线**,只有第 1 / 2 / 6 个带文字(小 / 默认 / 大)。
+   * 档位从 CHAT_FONT_SIZES 里按等距取样,保证刻度间距一致 ——
+   * 直接写死数值会在改动字号档位时与滑块对不上。
+   *
+   *  13 ─ 16 ─ 17 ─ 18 ─ 19 ─ 20
+   *  小    默认             大
+   */
+  readonly fontMarks: { value: number; label: string }[] = (() => {
+    const picks = [0, 3, 4, 5, 6, 7];
+    return picks.map((i, idx) => ({
+      value: CHAT_FONT_SIZES[Math.min(i, CHAT_FONT_SIZES.length - 1)],
+      label: idx === 0 ? '小' : idx === 1 ? '默认' : idx === picks.length - 1 ? '大' : '',
+    }));
+  })();
 
   setChatFont(px: unknown): void {
     if (typeof px === 'number') this.chatFont.set(px);

+ 26 - 24
src/app/ui/components/slider/slider.html

@@ -1,29 +1,31 @@
-<div class="w-full">
-  <div class="flex items-center gap-3">
-    <input
-      type="range"
-      class="ui-slider-input w-full"
-      [style]="trackStyle()"
-      [min]="min()"
-      [max]="max()"
-      [step]="step()"
-      [value]="value()"
-      [disabled]="disabled()"
-      (input)="onInput($event)"
-    />
-    @if (showValue()) {
-      <span class="w-12 shrink-0 text-right text-sm font-medium tabular-nums text-ink-2">{{ value() }}</span>
-    }
-  </div>
+<div class="ui-slider">
+  <input
+    type="range"
+    class="ui-slider-input"
+    [style]="trackStyle()"
+    [min]="min()"
+    [max]="max()"
+    [step]="step()"
+    [value]="value()"
+    [disabled]="disabled()"
+    [attr.aria-label]="label() || null"
+    (input)="onInput($event)"
+  />
+
   @if (marks().length) {
-    <div class="relative mt-2 h-5">
+    <!-- 刻度短线:每个刻度一根,等距分布(2026-11-01) -->
+    <div class="ui-slider-ticks" aria-hidden="true">
       @for (mark of marks(); track mark.value) {
-        <div class="absolute -translate-x-1/2 text-center" [style.left.%]="markPercent(mark)">
-          <div class="mx-auto h-1.5 w-px bg-edge-strong"></div>
-          @if (mark.label) {
-            <div class="mt-0.5 text-[10px] leading-none text-faint">{{ mark.label }}</div>
-          }
-        </div>
+        <span class="tick" [style.left.%]="markPercent(mark)"></span>
+      }
+    </div>
+
+    <!-- 刻度文字:只有标记了 label 的刻度才显示 -->
+    <div class="ui-slider-labels" aria-hidden="true">
+      @for (mark of marks(); track mark.value) {
+        @if (mark.label) {
+          <span class="label" [style.left.%]="markPercent(mark)">{{ mark.label }}</span>
+        }
       }
     </div>
   }

+ 108 - 22
src/app/ui/components/slider/slider.scss

@@ -2,27 +2,54 @@
   display: block;
 }
 
-/* 原生 range 输入框:外观由本文件统一控制(WebKit + Firefox) */
+/*
+  滑块(2026-11-01 按参考稿定稿)
+
+  视觉取向:**极简量尺**。
+  · 轨道 3px、已填充用深色、未填充用浅灰 —— 对比清晰但不厚重
+  · 手柄是小号圆角胶囊(8×20),而不是大圆点,贴近参考稿
+  · 6 个等距刻度短线,只有首/次/尾带文字
+  · 所有标签统一弱化色,不做选中态高亮 —— 参考稿里三处文字是同一种灰
+*/
+
+.ui-slider {
+  position: relative;
+  /* 左右留出半个标签宽度的呼吸位,首尾文字才不会被容器裁掉 */
+  padding: 0 16px 24px;
+}
+
+/* ---------------- 轨道与手柄 ---------------- */
+
 .ui-slider-input {
   -webkit-appearance: none;
   appearance: none;
-  height: 16px;
+  display: block;
+  width: 100%;
+  /* 高度 = 手柄高度,保证手柄不被上下裁切 */
+  height: 20px;
+  margin: 0;
   background: transparent;
   cursor: pointer;
 
   &:disabled {
     cursor: not-allowed;
-    opacity: 0.5;
+    opacity: 0.45;
+  }
+
+  &:focus-visible {
+    outline: 2px solid var(--primary);
+    outline-offset: 4px;
+    border-radius: var(--r-sm);
   }
 
   /* ---- WebKit ---- */
   &::-webkit-slider-runnable-track {
-    height: 4px;
+    height: 3px;
     border-radius: 9999px;
     background: linear-gradient(
       to right,
-      var(--primary) 0%,
-      var(--primary) var(--slider-percent, 0%),
+      var(--ink) 0%,
+      var(--ink) var(--slider-percent, 0%),
       var(--edge-strong) var(--slider-percent, 0%)
     );
   }
@@ -30,39 +57,98 @@
   &::-webkit-slider-thumb {
     -webkit-appearance: none;
     appearance: none;
-    height: 16px;
-    width: 16px;
-    margin-top: -6px; /* (轨道 4px - 滑块 16px) / 2,垂直居中 */
+    /* 小号胶囊 8 × 20 */
+    height: 20px;
+    width: 8px;
+    /* (轨道 3px - 手柄 20px) / 2 = -8.5px */
+    margin-top: -8.5px;
     border-radius: 9999px;
+    /*
+      只手柄用 --surface 填充 + --ink 描边:
+      纯填充(全黑/全白)在深色主题下会与轨道糊成一片,
+      「浅芯 + 深边」在明暗两套主题里都能立刻定位。
+    */
     background: var(--surface);
-    border: 2px solid var(--primary);
-    box-shadow: var(--shadow-soft);
-    transition: transform 0.15s ease, border-color 0.15s ease;
+    border: 1.5px solid var(--ink);
+    box-sizing: border-box;
+    transition: transform 0.16s var(--ease-standard);
+  }
 
-    &:active {
-      transform: scale(1.15);
-    }
+  &:active::-webkit-slider-thumb {
+    transform: scaleY(1.08);
   }
 
   /* ---- Firefox ---- */
   &::-moz-range-track {
-    height: 4px;
+    height: 3px;
     border-radius: 9999px;
     background: var(--edge-strong);
   }
 
   &::-moz-range-progress {
-    height: 4px;
+    height: 3px;
     border-radius: 9999px;
-    background: var(--primary);
+    background: var(--ink);
   }
 
   &::-moz-range-thumb {
-    height: 12px;
-    width: 12px;
+    height: 20px;
+    width: 8px;
     border-radius: 9999px;
     background: var(--surface);
-    border: 2px solid var(--primary);
-    box-shadow: var(--shadow-soft);
+    border: 1.5px solid var(--ink);
+    box-sizing: border-box;
+  }
+}
+
+/* ---------------- 刻度短线 ---------------- */
+
+.ui-slider-ticks {
+  position: absolute;
+  /* 与轨道同坐标系:容器左右内边距即轨道起止 */
+  left: 16px;
+  right: 16px;
+  /*
+    轨道下沿在 11.5px(input 高 20、轨道 3px 居中)。
+    刻度从 13px 起 —— 只留 1.5px 间隙,视觉上像从轨道「长出来」的标尺,
+    而不是飘在下方的一排独立符号。
+  */
+  top: 13px;
+  height: 8px;
+  pointer-events: none;
+}
+
+.tick {
+  position: absolute;
+  top: 0;
+  width: 1px;
+  height: 5px;
+  transform: translateX(-50%);
+  background: var(--edge-strong);
+}
+
+/* ---------------- 刻度文字 ---------------- */
+
+.ui-slider-labels {
+  position: absolute;
+  left: 16px;
+  right: 16px;
+  top: 28px;
+  height: 18px;
+  pointer-events: none;
+}
+
+.label {
+  position: absolute;
+  transform: translateX(-50%);
+  font-size: var(--fs-12);
+  line-height: 18px;
+  color: var(--muted);
+  white-space: nowrap;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .ui-slider-input::-webkit-slider-thumb {
+    transition: none;
   }
 }

+ 5 - 0
src/app/ui/components/slider/slider.ts

@@ -27,6 +27,11 @@ export class UiSlider {
   readonly disabled = input(false, { transform: booleanAttribute });
   /** 是否显示当前数值 */
   readonly showValue = input(false, { transform: booleanAttribute });
+  /**
+   * 无障碍标签(2026-11-01)。
+   * 原生 range 没有可见文案时,屏幕阅读器只会念「滑块」,必须给一个明确名称。
+   */
+  readonly label = input('');
   /** 刻度标记 [{ value, label? }] */
   readonly marks = input<SliderMark[]>([]);