fix(web): soften the home entry pill borders

This commit is contained in:
jesse-ux
2026-09-22 13:05:07 +08:00
parent 92e79642d2
commit 45ec46176d
7 changed files with 106 additions and 17 deletions
+25 -8
View File
@@ -521,15 +521,32 @@ page. Three parts now, in reading order:
1040px against the session's 760px, so the input visibly narrowed the moment
a conversation started.
- **Entry pills** (`.starter-entry`): the two product entrances, rendered inside
`.composer-wrap` *below* the composer. 36px pills, hairline, accent icon, one
word each. Below them one line merges the daily trend with the rectification
hint, and an accuracy-boundary line appears when no natal minute is available.
`.composer-wrap` *below* the composer. The visual pill is 36px tall
(`min-height: 36px`). The transparent hit area is the `::after` box with
`inset: -4px`, so the touch target is 44px. One word each, accent icon 15px.
Below them one line merges the daily trend with the rectification hint, and
an accuracy-boundary line appears when no natal minute is available.
- **States:** default, hover, pressed, focus-visible, disabled, and `data-opening`
while a Case is being opened. Hover only moves the hairline; the surface tint
belongs to the pressed state, because a hover that shades the surface reads as
"selected" and sticks on touch. Opening a Case changes the cursor to `progress`
and nothing else — no spinner (unified-loading ruling).
- **States, measured:**
- Default border is `1px solid var(--starter-entry-border)`, and that token is
`color-mix(in srgb, var(--color-border) 62%, transparent)`. It is not
`--color-border-strong` and not `--color-action`. A `1px` shadow uses
`color-mix(in srgb, var(--color-ink) 4%, transparent)`.
- Hover (`@media (hover: hover)` only) darkens the border to
`color-mix(in srgb, var(--color-border-strong) 45%, var(--color-border))`.
It does not switch the border to `--color-action` and does not tint the fill.
- Pressed (`:active`) keeps the soft border, drops the shadow, and fills with
`color-mix(in srgb, var(--color-canvas-soft) 80%, var(--color-canvas))`.
- Focus-visible is a `3px` outline at
`color-mix(in srgb, var(--color-focus) 80%, transparent)` with
`outline-offset: 2px`. That ring is wider and more opaque than the 1px
default border.
- Disabled uses the shared `button:disabled` opacity. `data-opening="true"`
sets `cursor: progress` and adds no spinner and no “正在加载” copy.
- `prefers-reduced-motion: reduce` already collapses transition duration to
`.01ms` on `*`. Do not add a loop animation here.
- At `max-width: 375px` the row and each pill are `max-width: 100%` so the
label and 15px icon stay inside the row. The 44px hit area does not shrink.
- **Copy:** chat history titles daily readings as “8月22日 · 今日节奏”, typed natal
questions as “事业 · …”, and rectification as “8月21日 · 生时校正”. Clicking the
daily pill starts a consultation session immediately; it does not place the
+19 -8
View File
@@ -2238,19 +2238,21 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
.starter-entry {
--starter-entry-border: color-mix(in srgb, var(--color-border) 62%, transparent);
position: relative;
min-height: 36px;
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-4);
border: 1px solid var(--color-border-strong);
border: 1px solid var(--starter-entry-border);
border-radius: 999px;
background: var(--color-canvas);
box-shadow: 0 1px 1px color-mix(in srgb, var(--color-ink) 4%, transparent);
color: var(--color-ink);
cursor: pointer;
font-size: var(--type-body-sm);
transition: border-color 140ms ease-out;
transition: border-color 120ms ease-out, background-color 120ms ease-out, box-shadow 120ms ease-out;
}
.starter-entry > svg {
@@ -2272,22 +2274,31 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
content: "";
}
/* Pressed feedback is transient; hover only moves the hairline. A hover that
shades the surface reads as "selected" and sticks on touch devices. */
/* Pressed feedback is a quiet surface tint. Hover only darkens the same soft
border; it does not switch to the action color, which read as a selected
control and stuck on touch. */
.starter-entry:not(:disabled):active {
border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border-strong));
background: var(--color-action-soft);
border-color: var(--starter-entry-border);
background: color-mix(in srgb, var(--color-canvas-soft) 80%, var(--color-canvas));
box-shadow: none;
}
@media (hover: hover) {
.starter-entry:not(:disabled):hover { border-color: var(--color-action); }
.starter-entry:not(:disabled):hover {
border-color: color-mix(in srgb, var(--color-border-strong) 45%, var(--color-border));
}
}
.starter-entry:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent);
outline: 3px solid color-mix(in srgb, var(--color-focus) 80%, transparent);
outline-offset: 2px;
}
@media (max-width: 375px) {
.starter-entry-row { max-width: 100%; }
.starter-entry { max-width: 100%; }
}
.starter-entry-hint,
.starter-entry-boundary {
max-width: 560px;
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const design = readFileSync(new URL("../DESIGN.md", import.meta.url), "utf8");
function starterEntryBlock(): string {
const start = css.indexOf(".starter-entry {");
const end = css.indexOf(".starter-entry > svg");
assert.ok(start >= 0 && end > start);
return css.slice(start, end);
}
test("starter entries stay native buttons with a 36px pill and a 44px hit area", () => {
assert.match(starterHome, /<button[\s\S]*className="starter-entry"/);
assert.equal(starterHome.match(/className="starter-entry"/g)?.length, 2);
assert.doesNotMatch(starterHome, /starter-hero|product-entrypoint|daily-starlanguage-card|birth-rectification-card/);
assert.doesNotMatch(starterHome, /正在加载|InlineSpinner|animate-spin/);
const block = starterEntryBlock();
assert.match(block, /min-height:\s*36px/);
assert.match(block, /--starter-entry-border:\s*color-mix\(in srgb, var\(--color-border\) 62%, transparent\)/);
assert.match(block, /border:\s*1px solid var\(--starter-entry-border\)/);
assert.doesNotMatch(block, /border:\s*1px solid var\(--color-border-strong\)/);
assert.match(css, /\.starter-entry::after\s*\{[^}]*inset:\s*-4px/);
});
test("starter entry hover stays softer than the action color and focus is a 3px ring", () => {
const hover = css.slice(css.indexOf(".starter-entry:not(:disabled):hover"), css.indexOf(".starter-entry:focus-visible"));
assert.match(hover, /color-mix\(in srgb, var\(--color-border-strong\) 45%, var\(--color-border\)\)/);
assert.doesNotMatch(hover, /border-color:\s*var\(--color-action\)/);
assert.match(css, /\.starter-entry:focus-visible\s*\{[^}]*outline:\s*3px solid color-mix\(in srgb, var\(--color-focus\) 80%, transparent\)/);
assert.match(css, /\.starter-entry\[data-opening="true"\]\s*\{\s*cursor:\s*progress/);
assert.match(css, /@media \(max-width:\s*375px\)\s*\{[^}]*\.starter-entry-row\s*\{\s*max-width:\s*100%/);
assert.match(css, /@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*transition-duration:\s*\.01ms !important/);
assert.match(design, /--starter-entry-border/);
assert.match(design, /inset: -4px/);
});