feat(frontend): add a warm dark theme and close the DESIGN.md drift
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

The palette is restated for dark rather than inverted: elevation reads
through lightness on dark and through darkness on light, so the floor is
the darkest surface here and the second-lightest there. The clay hue is
kept and lifted, because #85432f is 2.1:1 on a dark ground. All 37
themeable tokens are covered, in an OS-preference block and a data-theme
block that a contract test keeps identical, and ink, action, danger,
success and warning are asserted at 4.5:1 against the dark canvas.

Four raw colors that would have stayed light-theme values are tokenised
(the avatar hairline, the sheen sweep, a one-off shadow, a literal
warning hex). The QR keeps literal white in both themes, since scanners
need light modules to be light, and print keeps white paper.

The four root boundary pages cannot read a token, so they restate the
handful they need in both themes. forbidden.tsx also stops painting a
bespoke near-black page in four colours that appear nowhere in the
palette, which broke the rule that dark ink is never a page-scale
surface.

Also fixes what the audit found in DESIGN.md itself: two ink values that
had drifted from the code, a motion tier documented at 360ms that was
never implemented, a breakpoint section claiming three tiers where the
stylesheet has eleven, an undocumented report-paper palette, and an admin
section describing a bespoke panel that antd + Refine replaced. Five
zero-reference admin rules go with it.

The sidebar gets the accent, opaque drawer, heading rank and empty-state
guidance settled earlier, and fenced code blocks finally get a container.

BUG-439.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
This commit is contained in:
Jesse_Chen
2026-08-29 12:01:50 +00:00
co-authored by Claude Opus 5
parent d02fa8bbf3
commit c2d705ef6e
13 changed files with 720 additions and 69 deletions
+41 -7
View File
@@ -1,17 +1,22 @@
"use client";
// These pages sit in the shared root layout segment and must not import the
// site stylesheet — that would drag the chat styles onto every admin route — so
// they cannot read a single design token. They restate the handful they need,
// in both themes, and those values are kept in step with the palette by hand.
import { useEffect } from "react";
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
const canvas = "var(--color-canvas, #fbfaf7)";
const ink = "var(--color-ink, #1d1d1f)";
const inkSecondary = "var(--color-ink-secondary, #5f5f59)";
const danger = "var(--color-danger, #9a2f2f)";
const border = "var(--color-border, #d8d6cf)";
const action = "var(--color-action, #85432f)";
const focus = "var(--color-focus, #85432f)";
const canvas = "var(--bp-canvas)";
const ink = "var(--bp-ink)";
const inkSecondary = "var(--bp-ink-soft)";
const danger = "var(--bp-danger)";
const border = "var(--bp-border)";
const action = "var(--bp-action)";
const focus = "var(--bp-action)";
const actionStyle = {
alignItems: "center",
@@ -55,6 +60,35 @@ export default function RootError({
}}
>
<style>{`
:root {
color-scheme: light;
--bp-canvas: #fbfaf7;
--bp-ink: #1d1d1f;
--bp-ink-soft: #5f5f59;
--bp-border: #d8d6cf;
--bp-action: #85432f;
--bp-danger: #9a2f2f;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--bp-canvas: #262624;
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-danger: #e08573;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--bp-canvas: #262624;
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-danger: #e08573;
}
.root-error-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
`}</style>
<TriangleAlert aria-hidden="true" color={danger} size={32} />
+44 -6
View File
@@ -1,20 +1,58 @@
// Sits in the shared root layout segment and must not import the site
// stylesheet — that would drag the chat styles onto every admin route — so it
// restates the few tokens it needs, in both themes, kept in step by hand.
// It used to paint a bespoke near-black page in colours that appear nowhere in
// the palette, which broke the rule that dark ink is punctuation and never a
// page-scale surface.
const canvas = "var(--bp-canvas)";
const ink = "var(--bp-ink)";
const inkSecondary = "var(--bp-ink-soft)";
const action = "var(--bp-action)";
export default function Forbidden() {
return (
<main
style={{
alignItems: "center",
background: "#11100e",
color: "#f2eee6",
background: canvas,
color: ink,
display: "flex",
minHeight: "100vh",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
justifyContent: "center",
minHeight: "100vh",
padding: "2rem",
}}
>
<style>{`
:root {
color-scheme: light;
--bp-canvas: #fbfaf7;
--bp-ink: #1d1d1f;
--bp-ink-soft: #5f5f59;
--bp-action: #85432f;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--bp-canvas: #262624;
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-action: #d4785a;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--bp-canvas: #262624;
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-action: #d4785a;
}
`}</style>
<section style={{ maxWidth: "32rem", textAlign: "center" }}>
<p style={{ color: "#c8a96b", letterSpacing: "0.12em" }}>403</p>
<h1>访</h1>
<p style={{ color: "#b8b1a5", lineHeight: 1.7 }}>
<p style={{ color: action, fontSize: "14px", fontWeight: 500, letterSpacing: "0.12em", margin: 0 }}>403</p>
<h1 style={{ color: ink, fontSize: "20px", fontWeight: 600, margin: "12px 0 0" }}>访</h1>
<p style={{ color: inkSecondary, fontSize: "14px", lineHeight: 1.7, margin: "12px 0 0" }}>
访
</p>
</section>
+36 -7
View File
@@ -1,12 +1,12 @@
"use client";
const canvas = "var(--color-canvas, #fbfaf7)";
const ink = "var(--color-ink, #1d1d1f)";
const inkSecondary = "var(--color-ink-secondary, #5f5f59)";
const danger = "var(--color-danger, #9a2f2f)";
const border = "var(--color-border, #d8d6cf)";
const action = "var(--color-action, #85432f)";
const focus = "var(--color-focus, #85432f)";
const canvas = "var(--bp-canvas)";
const ink = "var(--bp-ink)";
const inkSecondary = "var(--bp-ink-soft)";
const danger = "var(--bp-danger)";
const border = "var(--bp-border)";
const action = "var(--bp-action)";
const focus = "var(--bp-action)";
const actionStyle = {
alignItems: "center",
@@ -44,6 +44,35 @@ export default function GlobalError({
}}
>
<style>{`
:root {
color-scheme: light;
--bp-canvas: #fbfaf7;
--bp-ink: #1d1d1f;
--bp-ink-soft: #5f5f59;
--bp-border: #d8d6cf;
--bp-action: #85432f;
--bp-danger: #9a2f2f;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--bp-canvas: #262624;
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-danger: #e08573;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--bp-canvas: #262624;
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-danger: #e08573;
}
.global-error-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
`}</style>
<main style={{ maxWidth: "32rem", textAlign: "center" }}>
+191 -29
View File
@@ -119,6 +119,9 @@
--space-24: 96px;
--composer-reserve: 148px;
--ease-out: cubic-bezier(.22, 1, .36, 1);
/* Hairlines and sheens that are dark-on-light here and light-on-dark there. */
--ring-hairline: oklch(0 0 0 / .1);
--sheen: rgba(255, 255, 255, .54);
--sidebar-background: var(--color-sidebar);
--sidebar-solid: var(--color-sidebar-solid);
--sidebar-foreground: var(--color-ink);
@@ -135,6 +138,153 @@
--sidebar-width-mobile: min(86vw, 320px);
}
/* ── Dark theme ──────────────────────────────────────────────────────────────
Three states: no attribute follows the OS; data-theme pins a choice. The two
blocks below MUST declare the same token set — class-name-definition-contract
has a sibling test that fails if they drift apart. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
/* Warm dark, never a neutral black: the light theme's parchment reads warm, and
dropping to a cool grey would change the product's identity, not just its
brightness. Elevation reads through lightness — floor is darkest, raised
surfaces step up — which is the inverse of the light theme, where raised
surfaces step down. Roles are preserved, values are not simply inverted. */
--color-canvas-soft: #1a1a19;
--color-canvas: #262624;
--color-canvas-muted: #30302d;
--color-canvas-strong: #3b3a37;
/* The clay hue is kept and lifted: #85432f is too dark to read as an action on
a dark ground. Hover now lightens instead of darkening. */
--color-action: #d4785a;
--color-action-soft: #3a2620;
--color-action-hover: #e59273;
--color-focus: #d4785a;
--color-action-on-dark: #8f4a33;
--color-ink: #f2f0ea;
--color-ink-strong: #e2dfd6;
--color-ink-secondary: #b3afa4;
--color-ink-tertiary: #928e84;
--color-sidebar: rgba(31, 30, 29, .86);
--color-sidebar-solid: #1f1e1d;
--color-selected: rgba(255, 255, 255, .07);
--color-light-hover: rgba(255, 255, 255, .06);
--color-frosted: rgba(38, 38, 36, .86);
/* Punctuation inverts: a dark block on light becomes a light block on dark, and
the text riding the lifted clay button has to go dark to stay legible. */
--color-surface-dark: #f2f0ea;
--color-surface-dark-raised: #e2dfd6;
--color-surface-dark-soft: #d3d0c7;
--color-on-dark: #241f1c;
--color-dark-hover: rgba(29, 29, 31, .1);
--color-dark-muted: #4a4842;
--color-dark-border: rgba(29, 29, 31, .18);
--color-border: #3c3b37;
--color-border-strong: #56544e;
/* Semantic hues are lifted for contrast on a dark ground; the muted pairs stop
being pale washes and become dark tints of the same hue. */
--color-danger: #e08573;
--color-success: #74b189;
--color-warning: #d8a758;
--color-danger-muted: #3a2523;
--color-success-muted: #22301f;
/* A scrim has to darken a surface that is already dark, and shadows need real
opacity to register without a light ground to fall on. */
--color-scrim: rgba(0, 0, 0, .58);
--shadow-soft: 0 1px 2px rgba(0, 0, 0, .3), 0 8px 20px -18px rgba(0, 0, 0, .6);
--shadow-elevated: 0 1px 2px rgba(0, 0, 0, .35), 0 12px 28px -16px rgba(0, 0, 0, .55);
/* The report is a document, so its paper follows the theme rather than staying
a lit sheet in a dark room. */
--report-paper: #262624;
--report-rule: #3c3b37;
--report-accent: #d4785a;
/* Hairlines that were dark-on-light have to become light-on-dark. */
--ring-hairline: oklch(1 0 0 / .14);
--sheen: rgba(255, 255, 255, .16);
}
}
:root[data-theme="dark"] {
color-scheme: dark;
/* Warm dark, never a neutral black: the light theme's parchment reads warm, and
dropping to a cool grey would change the product's identity, not just its
brightness. Elevation reads through lightness — floor is darkest, raised
surfaces step up — which is the inverse of the light theme, where raised
surfaces step down. Roles are preserved, values are not simply inverted. */
--color-canvas-soft: #1a1a19;
--color-canvas: #262624;
--color-canvas-muted: #30302d;
--color-canvas-strong: #3b3a37;
/* The clay hue is kept and lifted: #85432f is too dark to read as an action on
a dark ground. Hover now lightens instead of darkening. */
--color-action: #d4785a;
--color-action-soft: #3a2620;
--color-action-hover: #e59273;
--color-focus: #d4785a;
--color-action-on-dark: #8f4a33;
--color-ink: #f2f0ea;
--color-ink-strong: #e2dfd6;
--color-ink-secondary: #b3afa4;
--color-ink-tertiary: #928e84;
--color-sidebar: rgba(31, 30, 29, .86);
--color-sidebar-solid: #1f1e1d;
--color-selected: rgba(255, 255, 255, .07);
--color-light-hover: rgba(255, 255, 255, .06);
--color-frosted: rgba(38, 38, 36, .86);
/* Punctuation inverts: a dark block on light becomes a light block on dark, and
the text riding the lifted clay button has to go dark to stay legible. */
--color-surface-dark: #f2f0ea;
--color-surface-dark-raised: #e2dfd6;
--color-surface-dark-soft: #d3d0c7;
--color-on-dark: #241f1c;
--color-dark-hover: rgba(29, 29, 31, .1);
--color-dark-muted: #4a4842;
--color-dark-border: rgba(29, 29, 31, .18);
--color-border: #3c3b37;
--color-border-strong: #56544e;
/* Semantic hues are lifted for contrast on a dark ground; the muted pairs stop
being pale washes and become dark tints of the same hue. */
--color-danger: #e08573;
--color-success: #74b189;
--color-warning: #d8a758;
--color-danger-muted: #3a2523;
--color-success-muted: #22301f;
/* A scrim has to darken a surface that is already dark, and shadows need real
opacity to register without a light ground to fall on. */
--color-scrim: rgba(0, 0, 0, .58);
--shadow-soft: 0 1px 2px rgba(0, 0, 0, .3), 0 8px 20px -18px rgba(0, 0, 0, .6);
--shadow-elevated: 0 1px 2px rgba(0, 0, 0, .35), 0 12px 28px -16px rgba(0, 0, 0, .55);
/* The report is a document, so its paper follows the theme rather than staying
a lit sheet in a dark room. */
--report-paper: #262624;
--report-rule: #3c3b37;
--report-accent: #d4785a;
/* Hairlines that were dark-on-light have to become light-on-dark. */
--ring-hairline: oklch(1 0 0 / .14);
--sheen: rgba(255, 255, 255, .16);
}
* { box-sizing: border-box; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.phrase-nowrap { white-space: nowrap; }
@@ -158,7 +308,7 @@ button:disabled { cursor: default; opacity: .45; }
.credit-button span { display: inline-flex; align-items: center; margin: 0; color: inherit; font-size: inherit; line-height: 1; }
.credit-button .credit-icon { width: 16px; height: 16px; flex: 0 0 auto; }
.welcome-mark { margin-bottom: 20px; }
.welcome h1, .auth-panel h1, .admin-header h1 { margin: 0; font-size: clamp(30px, 4vw, 42px); font-weight: 600; letter-spacing: -.035em; line-height: 1.1; }
.welcome h1, .auth-panel h1 { margin: 0; font-size: clamp(30px, 4vw, 42px); font-weight: 600; letter-spacing: -.035em; line-height: 1.1; }
.welcome-copy { max-width: 520px; margin: 13px 0 28px; color: var(--color-ink-secondary); font-size: 15px; line-height: 1.65; }
.onboarding-card-reveal { min-width: 0; max-width: 100%; animation: onboarding-card-enter 180ms var(--ease-out) both; }
.onboarding-card-reveal-inner { min-width: 0; max-width: 100%; min-height: 0; }
@@ -383,8 +533,6 @@ button:disabled { cursor: default; opacity: .45; }
.code-form label { display: grid; gap: 7px; }
.generated-list { display: grid; gap: 8px; margin-top: 16px; }
.generated-list code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.admin-table { width: 100%; border-collapse: collapse; white-space: nowrap; }
.admin-table tr:last-child td { border-bottom: 0; }
.status-已过期, .status-已兑换 { color: var(--color-ink-secondary); }
.empty-cell { color: var(--color-ink-secondary); text-align: center !important; }
@@ -465,12 +613,12 @@ button:disabled { cursor: default; opacity: .45; }
@media (prefers-reduced-transparency: reduce) {
.sidebar { background: var(--sidebar-solid); backdrop-filter: none; }
.chat-header, .composer-wrap, .admin-header { background: var(--color-canvas); backdrop-filter: none; }
.chat-header, .composer-wrap { background: var(--color-canvas); backdrop-filter: none; }
}
@media (prefers-contrast: more) {
:root { --color-ink-secondary: var(--color-ink-strong); --color-ink-tertiary: var(--color-ink-secondary); --color-border: var(--color-border-strong); }
.chat-header, .composer-wrap, .admin-header { backdrop-filter: none; }
.chat-header, .composer-wrap { backdrop-filter: none; }
.button-primary, .button-secondary, input, select, .composer { border-width: 2px; }
.session-list [data-active="true"]::before { width: var(--space-1); }
.sidebar, .account-menu-popup, [role="tooltip"] { border-width: 2px; }
@@ -535,12 +683,15 @@ button:disabled { cursor: default; opacity: .45; }
[data-sidebar="sidebar"]:focus { outline: none; }
.brand-row { display: flex; align-items: center; min-height: 48px; gap: var(--space-3); padding: 0 var(--space-2); font-family: var(--font-display); font-size: var(--type-title-lg); letter-spacing: -.3px; }
.brand-row strong { font-weight: 400; }
.brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px oklch(0 0 0 / .1); }
.auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px oklch(0 0 0 / .1); }
.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; }
.brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--ring-hairline); }
.auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px var(--ring-hairline); }
.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; }
.report-nav-button { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; }
.report-nav-button:hover,
.new-chat:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.report-nav-button:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
/* 新建对话 is the sidebar's single high-signal action. DESIGN.md keeps the
action color scarce, so it stays tinted text on a transparent row rather
than a filled block — no page-scale color surface. */
.new-chat:hover { background: var(--sidebar-accent); color: var(--color-action-hover); }
.new-chat svg,
.report-nav-button svg,
.sidebar-label > svg,
@@ -559,7 +710,9 @@ button:disabled { cursor: default; opacity: .45; }
min-height: 44px;
align-items: center;
gap: var(--space-2);
color: var(--color-ink-tertiary);
/* A section heading has to outrank its own body copy. Both sat on
ink-tertiary, so the whole nav read as one flat grey. */
color: var(--color-ink-secondary);
font-size: var(--type-caption);
font-weight: 500;
letter-spacing: 0;
@@ -862,7 +1015,7 @@ button:disabled { cursor: default; opacity: .45; }
padding: var(--space-8) var(--space-8) var(--space-16);
}
.message { display: flex; animation: message-enter 160ms var(--ease-out) both; padding: var(--space-2) 0; }
.agent-avatar { width: 32px; height: 32px; display: block; flex: 0 0 32px; margin-top: var(--space-2); border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px oklch(0 0 0 / .1); }
.agent-avatar { width: 32px; height: 32px; display: block; flex: 0 0 32px; margin-top: var(--space-2); border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--ring-hairline); }
.message-content { min-width: 0; max-width: min(80%, 680px); }
.message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
.message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; }
@@ -1183,6 +1336,20 @@ button:disabled { cursor: default; opacity: .45; }
.message-markdown strong { color: var(--color-ink); font-weight: 500; }
.message-markdown a { text-underline-offset: 3px; color: var(--color-action); }
.message-markdown code { padding: 2px 5px; font-size: .9em; border-radius: var(--radius-xs); background: var(--color-canvas-muted); font-family: var(--font-mono); }
/* Fenced blocks had no rule at all: a bare <pre> keeps `white-space: pre` and no
overflow, so one long line spilled out of the message column, while the <code>
inside still wore the inline chip background. The block scrolls inside itself
and the chip styling is reset. */
.markdown-code { margin: 0 0 14px; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-muted); }
.markdown-code-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); min-height: 36px; padding: 0 var(--space-2) 0 var(--space-4); border-bottom: 1px solid var(--color-border); }
.markdown-code-language { color: var(--color-ink-tertiary); font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em; }
.markdown-code-copy { display: inline-flex; align-items: center; gap: var(--space-1); min-height: 28px; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-ink-secondary); cursor: pointer; font-size: var(--type-caption); transition: background-color 120ms ease-out, color 120ms ease-out; }
.markdown-code-copy svg { width: 13px; height: 13px; }
.markdown-code pre { margin: 0; padding: 0; overflow: hidden; background: none; }
.markdown-code pre > code { display: block; overflow-x: auto; padding: var(--space-3) var(--space-4); border-radius: 0; background: none; font-size: 13px; line-height: 1.6; white-space: pre; -webkit-overflow-scrolling: touch; }
@media (hover: hover) {
.markdown-code-copy:hover { background: var(--color-canvas-strong); color: var(--color-ink); }
}
.technique-audit {
margin-top: var(--space-5);
padding-top: var(--space-3);
@@ -1403,7 +1570,7 @@ button:disabled { cursor: default; opacity: .45; }
.redeem-modal { width: min(100%, 420px); }
.paywall-modal { width: min(100%, 480px); }
.logout-modal { width: min(100%, 400px); }
.account-modal h2, .auth-panel h1, .admin-header h1 { font-family: var(--font-display); font-weight: 400; letter-spacing: -.5px; text-wrap: balance; }
.account-modal h2, .auth-panel h1 { font-family: var(--font-display); font-weight: 400; letter-spacing: -.5px; text-wrap: balance; }
.account-modal h2 { margin: 0; font-size: var(--type-display-sm); letter-spacing: -.025em; }
.account-modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding-bottom: var(--space-5); }
.dialog-close { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; border: 0; cursor: pointer; transition: background-color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); background: var(--color-canvas-muted); }
@@ -1485,25 +1652,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.admin-page { background: var(--color-canvas-soft); }
.admin-app-shell { height: 100dvh; min-height: 0; overflow-y: auto; }
.admin-app-shell > *, .admin-app-shell .ant-layout { min-height: 100%; }
.admin-header { position: sticky; z-index: 4; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--color-border); min-height: 88px; padding: 0 var(--space-8); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); }
.admin-header h1 { font-size: var(--type-display-md); }
.admin-scroll { display: grid; width: min(1200px, 100%); gap: var(--space-5); margin: 0 auto; padding: var(--space-8) var(--space-8) var(--space-16); }
.admin-section { border: 1px solid var(--color-border); min-width: 0; padding: var(--space-6); border-color: var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); }
.code-form > * { min-width: 0; }
.section-title h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 400; letter-spacing: -.3px; }
.section-title p { margin: 6px 0 0; color: var(--color-ink-secondary); font-size: var(--type-caption); }
.generated-list > div { min-height: 48px; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 12px; padding: 0 12px; border: 0; border-radius: var(--radius-md); background: var(--color-canvas-muted); }
.generated-list code, .admin-table code { font-family: var(--font-mono); }
.generated-list code { font-family: var(--font-mono); }
.generated-list button { min-width: 44px; min-height: 44px; border: 0; background: transparent; cursor: pointer; color: var(--color-action); }
.admin-table-wrap { margin-top: 16px; overflow: auto; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); }
.admin-table th, .admin-table td { border-bottom: 1px solid var(--color-border); text-align: left; padding: var(--space-4); color: var(--color-ink); font-size: 13px; }
.admin-table th { background: var(--color-canvas-muted); color: var(--color-ink-secondary); font-weight: 500; }
.code-status { display: inline-flex; min-height: 28px; align-items: center; padding: 0 9px; border-radius: var(--radius-md); background: var(--color-canvas-muted); color: var(--color-ink-secondary); }
.status-可用 { background: var(--color-success-muted); color: var(--color-success); }
.status-已过期, .status-已兑换, .empty-cell { color: var(--color-ink-tertiary); }
@media (hover: hover) {
.new-chat:not(:disabled):hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.new-chat:not(:disabled):hover { background: var(--sidebar-accent); color: var(--color-action-hover); }
.composer button:not(:disabled):hover, .button-primary:not(:disabled):hover { background: var(--color-action-hover); }
.model-selector-trigger:not(:disabled):hover { background: var(--color-canvas-muted); color: var(--color-ink); }
.model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); }
@@ -1525,7 +1685,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
@media (max-width: 767px) {
.chat-app { grid-template-columns: 1fr; }
[data-sidebar="sidebar"] { position: fixed; z-index: 12; inset: 0 auto 0 0; width: var(--sidebar-width-mobile); height: 100dvh; padding: max(var(--space-3), env(safe-area-inset-top)) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); border-right: 1px solid var(--sidebar-border); }
/* The glass surface assumes a light backdrop. On mobile the drawer floats
above the scrim instead, which darkens and flattens it, so the drawer
takes the solid token — same treatment as prefers-reduced-transparency. */
[data-sidebar="sidebar"] { background: var(--sidebar-solid); backdrop-filter: none; position: fixed; z-index: 12; inset: 0 auto 0 0; width: var(--sidebar-width-mobile); height: 100dvh; padding: max(var(--space-3), env(safe-area-inset-top)) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); border-right: 1px solid var(--sidebar-border); }
[data-sidebar="sidebar"][data-mobile-open="false"] { visibility: hidden; transform: translateX(-100%); }
[data-sidebar="sidebar"][data-mobile-open="true"] { visibility: visible; transform: translateX(0); }
.sidebar-scrim { position: fixed; z-index: 11; inset: 0; display: block; border: 0; background: var(--color-scrim); opacity: 1; visibility: visible; }
@@ -1563,8 +1726,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.auth-story > div > p, .auth-footnote { display: none; }
.auth-panel { justify-content: flex-start; padding: var(--space-8) var(--space-6) max(var(--space-8), env(safe-area-inset-bottom)); }
.auth-panel h1 { font-size: var(--type-display-md); }
.admin-header { padding: 0 var(--space-4); }
.admin-scroll { padding: var(--space-6) var(--space-4) var(--space-12); }
.birth-time-event-row { grid-template-columns: 1fr; }
}
@@ -1589,7 +1750,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.auth-story h2 { font-size: var(--type-title-lg); }
.auth-panel { padding: var(--space-6) var(--space-5) max(var(--space-6), env(safe-area-inset-bottom)); }
.auth-panel h1 { font-size: var(--type-display-sm); }
.admin-section { padding: var(--space-5); }
}
.session-delete-overlay { z-index: 100; }
.session-delete-confirmation p { margin: 0; color: var(--color-ink-secondary); }
@@ -1702,7 +1862,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(112deg, transparent 22%, rgba(255, 255, 255, .54) 46%, transparent 68%);
background: linear-gradient(112deg, transparent 22%, var(--sheen) 46%, transparent 68%);
content: "";
transform: translateX(-48%);
transition: opacity 220ms ease-out, transform 520ms var(--ease-out);
@@ -2170,7 +2330,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.conversation:not(.is-empty):not(.is-rectification) + .composer-wrap .composer {
min-height: 64px;
border-radius: var(--radius-xl);
box-shadow: 0 1px 2px rgba(29, 29, 31, .05), 0 12px 32px -24px rgba(29, 29, 31, .34);
box-shadow: var(--shadow-soft);
}
@media (max-width: 767px) {
@@ -2863,6 +3023,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-candidate { min-height: 122px; padding: 12px; scroll-snap-align: start; }
}
/* The QR stays on literal white in both themes: scanners need the light modules
to actually be light, so this surface is not themed. */
.payment-qr-wrap { position: relative; width: min(220px, 72vw); aspect-ratio: 1; margin: 14px auto; padding: 10px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: #fff; box-shadow: var(--shadow-elevated); }
.payment-qr-wrap img { display: block; width: 100%; height: 100%; object-fit: contain; }
.payment-qr-badge { position: absolute; top: 50%; left: 50%; display: grid; width: 44px; height: 44px; padding: 4px; transform: translate(-50%, -50%); border: 4px solid #fff; border-radius: 12px; background: #fff; box-shadow: 0 2px 10px rgb(0 0 0 / 18%); }
@@ -2988,7 +3150,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.personal-report-chart-card h3 { margin: 0; font-size: 18px; }
.personal-report-claim-status { flex: 0 0 auto; color: var(--color-ink-secondary); font-size: 11px; font-weight: 650; line-height: 1.5; }
.personal-report-claim-status.is-success { color: var(--color-success); }
.personal-report-claim-status.is-warning { color: #866018; }
.personal-report-claim-status.is-warning { color: var(--color-warning); }
.personal-report-claim-status.is-danger { color: var(--color-danger); }
.personal-report-table-wrap { max-width: 100%; overflow-x: auto; }
.personal-report-planet-table { margin-top: 18px; font-size: 12px; }
+38 -5
View File
@@ -1,11 +1,15 @@
// These pages sit in the shared root layout segment and must not import the
// site stylesheet — that would drag the chat styles onto every admin route — so
// they cannot read a single design token. They restate the handful they need,
// in both themes, and those values are kept in step with the palette by hand.
import { cloneElement, type CSSProperties, type ReactElement } from "react";
import Link from "next/link";
const canvas = "var(--color-canvas, #fbfaf7)";
const ink = "var(--color-ink, #1d1d1f)";
const inkSecondary = "var(--color-ink-secondary, #5f5f59)";
const border = "var(--color-border, #d8d6cf)";
const focus = "var(--color-focus, #85432f)";
const canvas = "var(--bp-canvas)";
const ink = "var(--bp-ink)";
const inkSecondary = "var(--bp-ink-soft)";
const border = "var(--bp-border)";
const focus = "var(--bp-action)";
const actionStyle = {
alignItems: "center",
@@ -56,6 +60,35 @@ export default function RootNotFound() {
}}
>
<style>{`
:root {
color-scheme: light;
--bp-canvas: #fbfaf7;
--bp-ink: #1d1d1f;
--bp-ink-soft: #5f5f59;
--bp-border: #d8d6cf;
--bp-action: #85432f;
--bp-danger: #9a2f2f;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--bp-canvas: #262624;
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-danger: #e08573;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--bp-canvas: #262624;
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-danger: #e08573;
}
.root-not-found-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
`}</style>
<p style={{ color: inkSecondary, fontSize: "14px", fontWeight: 500, letterSpacing: "0.12em", margin: 0 }}>
+2 -2
View File
@@ -230,7 +230,7 @@ export function AppSidebar({
<ChevronDown className="sidebar-section-chevron" size={16} strokeWidth={1.75} aria-hidden="true" />
</summary>
<SidebarGroupContent className="sidebar-nested">
{favoriteSessions.length === 0 ? <p className="sidebar-empty"></p> : (
{favoriteSessions.length === 0 ? <p className="sidebar-empty"></p> : (
<SidebarMenu className="session-list">
{favoriteSessions.map((session, index) => renderSession(session, index))}
</SidebarMenu>
@@ -247,7 +247,7 @@ export function AppSidebar({
<ChevronDown className="sidebar-section-chevron" size={16} strokeWidth={1.75} aria-hidden="true" />
</summary>
<SidebarGroupContent className="sidebar-nested">
{historySessions.length === 0 ? <p className="sidebar-empty"></p> : (
{historySessions.length === 0 ? <p className="sidebar-empty"></p> : (
<SidebarMenu className="session-list">
{historySessions.map((session, index) => renderSession(session, favoriteSessions.length + index))}
</SidebarMenu>
@@ -3,6 +3,7 @@
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { MarkdownCodeBlock } from "@/components/markdown-code-block";
import { promoteDefinitionLists } from "@/lib/chat-definition-lists";
const markdownComponents: Components = {
@@ -16,6 +17,7 @@ const markdownComponents: Components = {
<table>{children}</table>
</div>
),
pre: ({ children }) => <MarkdownCodeBlock>{children}</MarkdownCodeBlock>,
ul: ({ children }) => <ul className="markdown-list">{children}</ul>,
ol: ({ children }) => <ol className="markdown-list">{children}</ol>,
};
@@ -0,0 +1,58 @@
"use client";
import { Check, Copy } from "lucide-react";
import { isValidElement, useEffect, useRef, useState, type ReactNode } from "react";
/** The fenced language, from the `language-xxx` class react-markdown puts on <code>. */
function fencedLanguage(node: ReactNode): string {
if (!isValidElement<{ className?: string }>(node)) return "";
return /language-([\w+-]+)/.exec(node.props.className ?? "")?.[1] ?? "";
}
/** The raw source inside the fence, for the copy button. */
function fencedSource(node: ReactNode): string {
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(fencedSource).join("");
if (isValidElement<{ children?: ReactNode }>(node)) return fencedSource(node.props.children);
return "";
}
export function MarkdownCodeBlock({ children }: { readonly children?: ReactNode }) {
const [copied, setCopied] = useState(false);
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const language = fencedLanguage(children);
const source = fencedSource(children);
useEffect(() => () => {
if (resetTimer.current) clearTimeout(resetTimer.current);
}, []);
async function copy() {
try {
await navigator.clipboard.writeText(source);
} catch {
return;
}
setCopied(true);
if (resetTimer.current) clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => setCopied(false), 1600);
}
return (
<div className="markdown-code">
<div className="markdown-code-bar">
<span className="markdown-code-language">{language || "代码"}</span>
<button
aria-label={copied ? "已复制代码" : "复制代码"}
className="markdown-code-copy"
onClick={() => void copy()}
type="button"
>
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
<span>{copied ? "已复制" : "复制"}</span>
</button>
</div>
<pre>{children}</pre>
</div>
);
}