feat(web): open design-system easter egg at /design-system route (#1328)

* feat(web): open design-system easter egg at /design-system route

Replace the long-press-logo iframe overlay, which loaded a separately maintained static design-system.html, with a real /design-system route. The new view aliases to the product design tokens, so the design system is maintained in one place. Adds vue-router for the route, removes the duplicate static HTML copies, and exempts the showcase view from the style scanner.

* fix(web): lazy-load the design-system view

Address Codex review: load the 2.4k-line showcase via defineAsyncComponent so it is code-split and fetched only when /design-system is visited, instead of bloating the initial bundle for every load.

* chore(nix): update pnpmDeps hash for vue-router

Adding vue-router changed pnpm-lock.yaml, which invalidated the fetchPnpmDeps hash. Set it to the value reported by the nix build.

* fix(web): return to app root when closing the design system

In-page nav anchors push hash history entries, so router.back() only stepped through them and required multiple clicks to leave. Navigate to / directly; the client lives above the route so session state is preserved.

* feat(web): let /design-system bypass the auth gate

Render the design-system route ahead of the auth/server gates and skip the /login rewrite for it, so a direct deep link shows the showcase even before the app is OAuth-ready. The view is read-only and holds no user data, matching the old static page behavior.

* fix(web): make the design-system root scrollable

The route renders inside .app-shell (height:100dvh; overflow:hidden), so a position:fixed root could be clipped. Make .ds-page a flex item that fills the shell and scrolls internally, so later sections and hash navigation remain reachable.

* fix(web): preserve /design-system during initial session load

On load the app auto-selects the first session and rewrites the URL to /sessions/<id>, which clobbered a deep-linked /design-system. Skip the session URL write while on the design-system route so refreshing keeps the route.

* fix(web): restore the prior session URL when leaving the design system

Record the URL on entry to /design-system and navigate back to it on close, so a /sessions/<id> URL is preserved instead of falling back to /. This also keeps the earlier fix that sidesteps in-page hash anchors.

* fix(web): capture the real browser URL before opening the design system

Session URLs are rewritten via the native history API, so vue-router's from.fullPath can be stale ('/' after a session is selected). Read window.location on entry instead, falling back to / for a direct deep link.

* fix(web): sync the active session URL when leaving the design system

After a direct deep link to /design-system the app auto-selects a session but the address stays '/'. On close, fall back to the active session's canonical URL so the address bar matches the displayed session.

* fix(web): capture the design-system return path at logo entry

Capture window.location in the logo long-press handler instead of a navigation guard. The guard fired on browser Back/Forward too and overwrote the return path with the design-system URL itself; capturing only at the explicit entry action avoids that.

* fix(web): replace the design-system route when closing

Use router.replace instead of push for the captured return URL, so closing does not append a second app URL after /design-system and the browser Back button returns to the page before the easter egg.

* revert(web): drop the design-system auth-gate bypass

Keep /design-system behind the auth gate so it has a single in-app entry (logo long-press). This removes the deep-link machinery (auth exemption, active-session fallback) that drove most of the URL/session edge cases, while keeping the lazy-loaded route, the flex scroll fix, the logo-entry return-path capture, and replace-on-close.

* refactor(web): rebuild the design-system easter egg as an in-app overlay

The easter egg is a hidden, read-only spec viewer opened by long-pressing the logo; it does not need to be a URL route. Replace the vue-router approach with an overlay: Sidebar opens a lazy-loaded DesignSystemView in a body-teleported full-screen overlay, dismissed by the Back button or Escape. This removes vue-router, the route, the auth-gate exemption, the session-URL guards, and the return-path machinery — none of which the feature needs.

* chore(nix): restore pnpmDeps hash after removing vue-router
This commit is contained in:
qer 2026-07-03 13:42:27 +08:00 committed by GitHub
parent 84d8d5b063
commit 01b65bdddc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 2431 additions and 4800 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Rebuild the web design-system easter egg as an in-app overlay that uses the app's real design tokens, so it stays in sync instead of drifting as a separate copy.

View file

@ -8,12 +8,12 @@ The browser web UI for Kimi Code — a peer to the TUI in `apps/kimi-code`. It t
## Design system (normative — required when modifying the UI)
- **Before changing any component, style, layout, or theme, read `apps/kimi-web/design/design-system.html`.** It is the canonical design system and visual spec for this app (tokens in §02, primitives in §03, chat in §04, theme rules in §05, style rules in §06). New and modified UI must match it.
- **Before changing any component, style, layout, or theme, read the design system view at `src/views/DesignSystemView.vue` (open it as an overlay: long-press the sidebar logo).** It is the canonical design system and visual spec for this app (tokens §02, primitives §03, chat §04, theme rules §05, style rules §06). It consumes the product tokens from `src/style.css` directly, so it stays in sync with the app. New and modified UI must match it.
- **Use the primitives in `src/components/ui/`.** The library covers Button, IconButton, Badge, Pill, Card, Input/Select/Textarea/Field, Dialog, Spinner, MoonSpinner, Link, Menu/MenuItem, SegmentedControl, Tabs, Switch, Checkbox, Avatar, EmptyState, Divider, Tooltip, Banner, Sheet, Skeleton, CommandBar, TopBar. One semantic = one component — do not hand-roll a bespoke button/badge/dialog/input for a single screen. When a primitive replaces an element, **delete the old scoped CSS** (do not append override blocks).
- **Use the tokens, not ad-hoc values.** Colors, fonts, radii, spacing, shadows, z-index, and motion come from the CSS custom properties in `src/style.css` (catalogued in §02). Canonical names are `--color-*` / `--radius-*` / `--space-*` / `--text-*` / `--font-*` / `--z-*` / `--shadow-*` / `--ease-*` / `--duration-*` / `--weight-*` / `--leading-*`. New tokens from §02 use the `--p-` prefix: `--p-focus-ring`, `--p-selection`, `--p-ic-sm/md/lg`, `--p-sidebar-w`, `--p-content-max/-wide`, `--p-bp-sm/-md`. (When §03 examples show `--p-text/--p-line/--p-accent/--p-r-*`, map them to the canonical `--color-text/--color-line/--color-accent/--radius-*`.)
- **Use the tokens, not ad-hoc values.** Colors, fonts, radii, spacing, shadows, z-index, and motion come from the CSS custom properties in `src/style.css` (catalogued in the design-system view §02). Canonical names are `--color-*` / `--radius-*` / `--space-*` / `--text-*` / `--font-*` / `--z-*` / `--shadow-*` / `--ease-*` / `--duration-*` / `--weight-*` / `--leading-*`. A small set of layout/focus tokens keep the `--p-` prefix: `--p-focus-ring`, `--p-selection`, `--p-ic-sm/md/lg`, `--p-sidebar-w`, `--p-content-max/-wide`, `--p-bp-sm/-md`.
- **The moon spinner (🌑…🌘) is reserved** for the chat "waiting for the agent's first response" state only, and is rendered solely by `ui/MoonSpinner.vue`; every other loading state uses the plain `Spinner`.
- **Run `pnpm --filter @moonshot-ai/kimi-web check:style`** (`scripts/check-style.mjs`) — it enforces the §06 anti-pattern rules (no-gradient, no-glassmorphism except TopBar `frost`, no-emoji-icon except moon, no-hardcoded-hex/font, radius/z/weight from scale). Do not add new violations.
- **Verify visually.** For any UI change, render it in the browser (light + dark, plus hover/focus states) and confirm it matches `design-system.html` and introduces no regression before considering it done. Build/typecheck/check-style are necessary but not sufficient.
- **Verify visually.** For any UI change, render it in the browser (light + dark, plus hover/focus states) and confirm it matches the design-system view and introduces no regression before considering it done. Build/typecheck/check-style are necessary but not sufficient.
## Layout (`src/`)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
#!/usr/bin/env node
// check-style.mjs — design-system §06 anti-pattern guard for apps/kimi-web.
//
// Scans src/** for the rules in design-system.html §06:
// Scans src/** for the rules in the design system (§06 of the DesignSystemView spec):
// no-gradient-text, no-glassmorphism (.frost exempt), no-color-glow,
// icon-from-registry (hand-written <svg>; Icon/Spinner/MoonSpinner + the
// 32x22 brand mark exempt), no-emoji-icon (moon in MoonSpinner exempt),
@ -41,6 +41,12 @@ const ICON_EXEMPT = new Set([
'components/GlobalLoading.vue',
]);
// Files entirely exempt from the §06 scan. The design-system showcase view is
// documentation/demo CSS (forced-dark previews, syntax-highlighting palettes,
// illustrative mockups) rather than product UI, so the anti-pattern rules do not
// apply to it.
const FILE_EXEMPT = new Set(['views/DesignSystemView.vue']);
const RADIUS_SCALE = new Set([4, 6, 8, 12, 16, 20, 999]);
const WEIGHT_OK = new Set([
'400', '500',
@ -96,6 +102,7 @@ function extractStyleBlocks(content) {
function checkFile(abs) {
const content = fs.readFileSync(abs, 'utf8');
const file = rel(abs);
if (FILE_EXEMPT.has(file)) return;
const isCss = abs.endsWith('.css');
const blocks = isCss ? [{ text: content, baseLine: 1 }] : extractStyleBlocks(content);
const domainExempt = DOMAIN_HEX_EXEMPT.has(file);

View file

@ -3,7 +3,7 @@
The old workspace rail and workspace tabs have been removed;
workspace switching, folding and renaming all live in the group header. -->
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { serverEndpointLabel } from '../api/config';
import { copyTextToClipboard } from '../lib/clipboard';
@ -510,8 +510,11 @@ function blinkOnce(): void {
}
// Logo long-press easter-egg: holding the Kimi mark for 1 second opens the
// design system page in a full-screen overlay. A short click still just blinks.
// design system as a full-screen overlay. A short click still just blinks.
// Pointer capture keeps the hold alive even if the pointer drifts off the mark.
const DesignSystemView = defineAsyncComponent(
() => import('../views/DesignSystemView.vue'),
);
const showDesignSystem = ref(false);
const EGG_HOLD_MS = 1000;
let logoPressTimer: ReturnType<typeof setTimeout> | undefined;
@ -541,22 +544,8 @@ function onLogoClick(): void {
blinkOnce();
}
function closeDesignSystem(): void {
showDesignSystem.value = false;
}
function onDesignSystemKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && showDesignSystem.value) closeDesignSystem();
}
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onDesignSystemKeydown);
}
onBeforeUnmount(() => {
clearTimeout(logoPressTimer);
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onDesignSystemKeydown);
}
});
</script>
@ -763,24 +752,10 @@ onBeforeUnmount(() => {
@select="onSelectSession"
@close="showSearch = false"
/>
<Teleport to="body">
<div
v-if="showDesignSystem"
class="ds-egg"
@mousedown.self="closeDesignSystem"
>
<div class="ds-egg-frame">
<div class="ds-egg-head">
<span class="ds-egg-title">Design system</span>
<IconButton size="sm" :label="t('diff.close')" @click="closeDesignSystem">
<Icon name="close" size="md" />
</IconButton>
</div>
<iframe class="ds-egg-iframe" src="/design-system.html" title="Design system" />
</div>
</div>
</Teleport>
</aside>
<Teleport to="body">
<DesignSystemView v-if="showDesignSystem" @close="showDesignSystem = false" />
</Teleport>
</template>
<style scoped>
@ -1067,47 +1042,4 @@ onBeforeUnmount(() => {
width: 14px;
}
/* Logo long-press easter-egg: a dialog showing the design system page. */
.ds-egg {
position: fixed;
inset: 0;
z-index: var(--z-max);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
background: rgba(13, 17, 23, 0.55);
}
.ds-egg-frame {
display: flex;
flex-direction: column;
width: min(1100px, 94vw);
height: min(820px, 92vh);
background: var(--color-surface-raised);
border: 1px solid var(--color-line);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
overflow: hidden;
}
.ds-egg-head {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
padding: 12px 14px 12px 18px;
border-bottom: 1px solid var(--color-line);
}
.ds-egg-title {
font-size: var(--text-base);
font-weight: 500;
color: var(--color-text);
}
.ds-egg-iframe {
flex: 1;
min-height: 0;
display: block;
width: 100%;
border: 0;
background: #fff;
}
</style>

File diff suppressed because it is too large Load diff