feat(web): Redesign frontend with Pencil-based design system#61
Conversation
- Rebuild design tokens (CSS custom properties) with new color palette (#FF8400 primary) - Add Geist + JetBrains Mono font loading from Google Fonts - Create TopBar component with lucide-vue-next icons - Refactor App.vue shell layout with sidebar + top bar + panel toggles - Restyle all core components (ChatMessage, ChatInput, Sidebar, ToolCallCard) - Restyle auxiliary components (ApprovalBanner, TodoPanel, SettingsDialog, TerminalPanel) - Update animations to use new primary color - Add DESIGN.md specification and Pencil design source files - Both dark and light themes verified working via CSS variable system
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds a formal design system and Penpot visuals, replaces Tailwind utility theming with a CSS-variable token system, introduces RightPanel/FileTree, TerminalInstance and tabbed TerminalPanel, and restyles many UI components to use token-driven inline styles. ChangesDesign System & Theme Implementation
Sequence Diagram(s): sequenceDiagram
participant Browser as Browser UI
participant TerminalInstance as TerminalInstance.vue
participant API as api.ptyCreate / api.ptyKill
participant PTYws as PTY WebSocket
Browser->>TerminalInstance: mount & request create PTY
TerminalInstance->>API: api.ptyCreate()
API-->>TerminalInstance: sessionId
TerminalInstance->>PTYws: open WebSocket -> WS URL(sessionId)
PTYws-->>TerminalInstance: binary/text output
TerminalInstance->>PTYws: user input (term.onData)
TerminalInstance->>API: api.ptyKill(sessionId) on cleanup
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b7b1e9affc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (panel === 'right-panel') { | ||
| rightPanelOpen.value = !rightPanelOpen.value | ||
| return |
There was a problem hiding this comment.
Implement right-panel toggle behavior
Clicking the new PanelRight button emits toggle-panel('right-panel'), but this branch only flips rightPanelOpen and that state is never consumed by the template (and active-panel never maps to 'right-panel'). In practice the button is inert: it opens no UI and never becomes active, which leaves a non-functional control in the top bar. Either render a panel from rightPanelOpen or remove the button until the panel exists.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
web/src/components/TerminalPanel.vue (1)
101-107:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDisconnect the theme
MutationObserverduring teardown to prevent observer accumulation on reconnect/unmount.
web/src/components/TerminalPanel.vue’sinitTerminal()creates a localconst observer = new MutationObserver(...)and starts observingdocument.documentElement, butcleanup()only disconnectsresizeObserver—soreconnect()(cleanup + initTerminal) creates additional theme observers.Suggested fix
let term: Terminal | null = null let fitAddon: FitAddon | null = null let ws: WebSocket | null = null let resizeObserver: ResizeObserver | null = null +let themeObserver: MutationObserver | null = null @@ - const observer = new MutationObserver(() => { + themeObserver = new MutationObserver(() => { if (term) { term.options.theme = isDarkMode() ? darkTheme : lightTheme } }) - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) @@ function cleanup() { + themeObserver?.disconnect() + themeObserver = null resizeObserver?.disconnect() resizeObserver = null if (ws) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/src/components/TerminalPanel.vue` around lines 101 - 107, The MutationObserver created in initTerminal() (const observer = new MutationObserver(...)) is never disconnected in cleanup(), causing duplicate observers after reconnect(); move the observer variable to component scope (e.g., a let observer variable alongside resizeObserver), assign it in initTerminal(), and call observer.disconnect() inside cleanup() (in addition to disconnecting resizeObserver) so the theme observer is properly torn down on unmount/reconnect.web/src/components/SettingsDialog.vue (1)
148-167:⚠️ Potential issue | 🟠 Major | ⚡ Quick winStop channel polling when the dialog closes (and avoid overlapping polls).
pollChannelState()createsinterval/setTimeoutas local variables and thewatch(() => props.open, ...)close (else) path only clearschannelQRContent(and a few other fields) — it never clears the polling timers. As a result, polling can keep running after the dialog is closed and continue updatingchannelState,channelQRContent, andstore.channelEnableduntil it reaches anenabled/disabledstate or the 180s timeout.Suggested fix
const channelLoginReminder = ref(false) +let channelPollInterval: ReturnType<typeof setInterval> | null = null +let channelPollTimeout: ReturnType<typeof setTimeout> | null = null +function stopChannelPolling() { + if (channelPollInterval) clearInterval(channelPollInterval) + if (channelPollTimeout) clearTimeout(channelPollTimeout) + channelPollInterval = null + channelPollTimeout = null +} watch(() => props.open, async (isOpen) => { if (isOpen) { @@ } else { + stopChannelPolling() channelQRContent.value = '' @@ function pollChannelState() { + stopChannelPolling() const previousState = channelState.value - const interval = setInterval(async () => { + channelPollInterval = setInterval(async () => { @@ - clearInterval(interval) + stopChannelPolling() } } catch { /* ignore */ } }, 2000) - setTimeout(() => clearInterval(interval), 180000) + channelPollTimeout = setTimeout(() => stopChannelPolling(), 180000) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/src/components/SettingsDialog.vue` around lines 148 - 167, pollChannelState creates local interval/timeout timers that never get cleared when the dialog closes, causing polls to continue and overlap; refactor by moving the interval and timeout IDs to outer-scope refs/variables (e.g., pollIntervalId and pollTimeoutId), update pollChannelState to first clear any existing pollIntervalId/pollTimeoutId before starting a new setInterval/setTimeout, and in the watch(() => props.open, ...) else/close branch clear and null out those stored timers and reset any state (channelQRContent/channelState/channelLoginReminder) so polling stops immediately when dialog closes and cannot overlap with a new poll.
🧹 Nitpick comments (1)
web/src/components/TopBar.vue (1)
20-24: ⚡ Quick winUse semantic CSS tokens instead of hardcoded status hex values.
Lines 21–23 hardcode colors while the rest of the component is token-driven. Switching these to semantic vars (e.g. warning/success/muted) will keep dark/light theming consistent.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/src/components/TopBar.vue` around lines 20 - 24, The computed status function currently returns hardcoded hex colors; update the status computed (function name: status) to return semantic CSS token values instead of hex codes—use the component/theme variables for warning, success and muted (e.g. var(--color-warning), var(--color-success), var(--color-muted) or your project's token names) when props.isRunning, props.wsConnected and the fallback offline branch are chosen so theming (dark/light) follows the rest of the component.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@DESIGN.md`:
- Around line 53-55: Update the font-role mappings to remove the inconsistency
by choosing a single font for display/headings and making the type scale match
it: revise the bullets "Primary / Display: JetBrains Mono", "Secondary / UI:
Geist", and "Fallback: Inter" so the "Primary / Display" entry matches the type
scale’s declared display/headings font (or update the type scale to use
JetBrains Mono), and also apply the same correction where the type scale is
defined (the section that lists display/headings and body/heading fonts) so the
spec consistently references the same font for display/headings and the same
fonts for UI/body.
- Around line 338-349: The fenced ASCII-layout block in DESIGN.md is missing a
language identifier which triggers markdown lint warnings; update the fenced
code block around the ASCII layout (the block showing the Sidebar/Topbar/Right
Panel diagram) to include a language hint (e.g., "text") after the opening
triple backticks so the fence reads as a language-labelled code block and
resolves the linting/rendering issue.
In `@design/landing-page.pen`:
- Line 2180: The JSON string assigned to "content" contains a typo in the curl
URL hostname (ra1w.githubusercontent.com); update that string so the hostname is
raw.githubusercontent.com (i.e., fix the install snippet in the "content" value
to use raw.githubusercontent.com) to restore a valid install command.
In `@web/src/App.vue`:
- Around line 161-169: The right-panel toggle updates rightPanelOpen in
togglePanel but that state is never bound to the UI; update the component so
rightPanelOpen controls the visible/active UI: either set activePanel when
toggling the 'right-panel' branch (e.g., togglePanel should set activePanel =
rightPanelOpen ? null : 'right-panel') or add a computed property that maps
rightPanelOpen to the active-panel prop used by the TopBar and the template;
ensure the template has a branch/slot that consumes activePanel (or
rightPanelOpen) to render the right panel content and that the TopBar
right-panel button emits/reads the same state so the button visibly opens/closes
the panel (also mirror the same pattern used for fileViewerOpen/file viewer).
---
Outside diff comments:
In `@web/src/components/SettingsDialog.vue`:
- Around line 148-167: pollChannelState creates local interval/timeout timers
that never get cleared when the dialog closes, causing polls to continue and
overlap; refactor by moving the interval and timeout IDs to outer-scope
refs/variables (e.g., pollIntervalId and pollTimeoutId), update pollChannelState
to first clear any existing pollIntervalId/pollTimeoutId before starting a new
setInterval/setTimeout, and in the watch(() => props.open, ...) else/close
branch clear and null out those stored timers and reset any state
(channelQRContent/channelState/channelLoginReminder) so polling stops
immediately when dialog closes and cannot overlap with a new poll.
In `@web/src/components/TerminalPanel.vue`:
- Around line 101-107: The MutationObserver created in initTerminal() (const
observer = new MutationObserver(...)) is never disconnected in cleanup(),
causing duplicate observers after reconnect(); move the observer variable to
component scope (e.g., a let observer variable alongside resizeObserver), assign
it in initTerminal(), and call observer.disconnect() inside cleanup() (in
addition to disconnecting resizeObserver) so the theme observer is properly torn
down on unmount/reconnect.
---
Nitpick comments:
In `@web/src/components/TopBar.vue`:
- Around line 20-24: The computed status function currently returns hardcoded
hex colors; update the status computed (function name: status) to return
semantic CSS token values instead of hex codes—use the component/theme variables
for warning, success and muted (e.g. var(--color-warning), var(--color-success),
var(--color-muted) or your project's token names) when props.isRunning,
props.wsConnected and the fallback offline branch are chosen so theming
(dark/light) follows the rest of the component.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 78865c3a-7be2-485b-b9d0-1efd70c70e50
⛔ Files ignored due to path filters (1)
web/pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (19)
.gitignoreDESIGN.mddesign/jcode-web.pendesign/landing-page.penweb/index.htmlweb/package.jsonweb/src/App.vueweb/src/components/ApprovalBanner.vueweb/src/components/ChatInput.vueweb/src/components/ChatMessage.vueweb/src/components/SettingsDialog.vueweb/src/components/Sidebar.vueweb/src/components/TerminalPanel.vueweb/src/components/TodoPanel.vueweb/src/components/ToolCallCard.vueweb/src/components/TopBar.vueweb/src/style.cssweb/src/styles/animations.cssweb/src/styles/tokens.css
| - **Primary / Display:** JetBrains Mono — used for the logo, code blocks, terminal previews, and any developer-facing content | ||
| - **Secondary / UI:** Geist — used for headings, body text, navigation, and all interface elements | ||
| - **Fallback:** Inter — used in documentation page content |
There was a problem hiding this comment.
Resolve font-role inconsistency in the spec.
The document says “Primary / Display: JetBrains Mono” but the type scale defines display/headings as Geist/Inter. This creates ambiguity for implementation.
Suggested wording adjustment
-- **Primary / Display:** JetBrains Mono — used for the logo, code blocks, terminal previews, and any developer-facing content
-- **Secondary / UI:** Geist — used for headings, body text, navigation, and all interface elements
+- **Primary / UI Display:** Geist — used for headings, body text, navigation, and interface elements
+- **Code / Monospace:** JetBrains Mono — used for logo accents, code blocks, terminal previews, and code-facing contentAlso applies to: 61-68
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@DESIGN.md` around lines 53 - 55, Update the font-role mappings to remove the
inconsistency by choosing a single font for display/headings and making the type
scale match it: revise the bullets "Primary / Display: JetBrains Mono",
"Secondary / UI: Geist", and "Fallback: Inter" so the "Primary / Display" entry
matches the type scale’s declared display/headings font (or update the type
scale to use JetBrains Mono), and also apply the same correction where the type
scale is defined (the section that lists display/headings and body/heading
fonts) so the spec consistently references the same font for display/headings
and the same fonts for UI/body.
| ``` | ||
| +------------------+-------------------------------+------------------+ | ||
| | Sidebar | Topbar (56px) | Right Panel | | ||
| | (260px) +-------------------------------+ (optional) | | ||
| | | | (320-400px) | | ||
| | - User row | Main Content | | | ||
| | - New conv btn | | | | ||
| | - Tabs | - Chat / Terminal / | - Agents | | ||
| | - Session list | Changes / File Editor | - Changes | | ||
| | - Status row | | | | ||
| +------------------+-------------------------------+------------------+ | ||
| ``` |
There was a problem hiding this comment.
Add a language hint to the fenced layout block.
The code fence is missing a language identifier, which triggers markdown linting and reduces readability in renderers.
Suggested fix
-```
+```text
+------------------+-------------------------------+------------------+
| Sidebar | Topbar (56px) | Right Panel |
...
-```
+```📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ``` | |
| +------------------+-------------------------------+------------------+ | |
| | Sidebar | Topbar (56px) | Right Panel | | |
| | (260px) +-------------------------------+ (optional) | | |
| | | | (320-400px) | | |
| | - User row | Main Content | | | |
| | - New conv btn | | | | |
| | - Tabs | - Chat / Terminal / | - Agents | | |
| | - Session list | Changes / File Editor | - Changes | | |
| | - Status row | | | | |
| +------------------+-------------------------------+------------------+ | |
| ``` |
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 338-338: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@DESIGN.md` around lines 338 - 349, The fenced ASCII-layout block in DESIGN.md
is missing a language identifier which triggers markdown lint warnings; update
the fenced code block around the ASCII layout (the block showing the
Sidebar/Topbar/Right Panel diagram) to include a language hint (e.g., "text")
after the opening triple backticks so the fence reads as a language-labelled
code block and resolves the linting/rendering issue.
| "type": "text", | ||
| "id": "M7Yq4", | ||
| "fill": "#ffffff", | ||
| "content": "curl -fsSL https://ra1w.githubusercontent.com/cnjack/jcode/main/script/install.sh | sh", |
There was a problem hiding this comment.
Fix the install URL hostname typo.
The command uses ra1w.githubusercontent.com instead of raw.githubusercontent.com, so the install snippet is invalid.
Suggested fix
-"content": "curl -fsSL https://ra1w.githubusercontent.com/cnjack/jcode/main/script/install.sh | sh",
+"content": "curl -fsSL https://raw.githubusercontent.com/cnjack/jcode/main/script/install.sh | sh",📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "content": "curl -fsSL https://ra1w.githubusercontent.com/cnjack/jcode/main/script/install.sh | sh", | |
| "content": "curl -fsSL https://raw.githubusercontent.com/cnjack/jcode/main/script/install.sh | sh", |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@design/landing-page.pen` at line 2180, The JSON string assigned to "content"
contains a typo in the curl URL hostname (ra1w.githubusercontent.com); update
that string so the hostname is raw.githubusercontent.com (i.e., fix the install
snippet in the "content" value to use raw.githubusercontent.com) to restore a
valid install command.
- TopBar: replace breadcrumb with session title/subtitle, add search pill, status dot only - Sidebar: remove Sessions/Files tabs, flat session list with 2-line items, pencil icon new chat button - ChatInput: single card layout, taller textarea, paperclip attach, toggle switch for auto-approve, orange Send button with text
- ChatInput: remove card wrapper, borderless design with subtle separator - TopBar: 3 functional icons (Terminal, Files, Changes) - RightPanel: new component with Files/Changes tabs, slide animation - FileTreePanel: directory browser with breadcrumbs and syntax-highlighted file preview - DiffViewer: adapted to vertical layout for narrow right panel - App.vue: replaced modal FileViewer with inline RightPanel, keyboard shortcuts ⌘⇧E/⌘⇧G
…e button - ChatInput: card-style container with rounded border, more padding, taller textarea - Sidebar: remove border-right and footer border-top for cleaner look - TerminalPanel: add X close button in header, emit close event
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@web/src/components/ChatInput.vue`:
- Around line 404-418: The model row currently nests a favorite <button> inside
the outer row <button> (the row that calls selectModel), which is invalid;
change the inner favorite control (class "fav-star") to a non-button interactive
element (e.g., a <span> or <div>) with role="button", tabindex="0", keep the
`@click.stop` handler calling store.toggleFavorite(p.id, m.id), and add keyboard
support (handle Enter/Space via `@keydown`) and the same :class binding using
store.isFavorite(p.id, m.id); alternatively, if you prefer, convert the outer
model row element (that calls selectModel) to a non-button clickable element
with role="button" so the favorite can remain a button — ensure either approach
removes nested <button> tags and preserves selectModel(p.id, m.id),
store.toggleFavorite(p.id, m.id), and accessibility/focus behavior.
In `@web/src/components/RightPanel.vue`:
- Around line 35-37: The close button using the X icon (button with class
"close-btn" and click handler emit('close')) lacks an accessible name; update
the button to include an explicit accessible label (for example add
aria-label="Close" or aria-label bound to a localized string) or include
visually-hidden text inside the button so screen readers announce its purpose,
ensuring the label matches your app's localization pattern if any.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: df61b342-ede8-4f8b-9b3f-076695d84ec9
📒 Files selected for processing (8)
web/src/App.vueweb/src/components/ChatInput.vueweb/src/components/DiffViewer.vueweb/src/components/FileTreePanel.vueweb/src/components/RightPanel.vueweb/src/components/Sidebar.vueweb/src/components/TerminalPanel.vueweb/src/components/TopBar.vue
| <button | ||
| v-for="m in p.models" | ||
| :key="m.id" | ||
| class="w-full px-3 py-1.5 text-xs text-left cursor-pointer select-none transition-colors group" | ||
| :class="store.providerName === p.id && store.modelName === m.id | ||
| ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10' | ||
| : 'text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 hover:text-zinc-700 dark:hover:text-zinc-200'" | ||
| class="dropdown-item group" | ||
| :class="{ active: store.providerName === p.id && store.modelName === m.id }" | ||
| @click="selectModel(p.id, m.id)" | ||
| > | ||
| <span class="truncate">{{ m.name || m.id }}</span> | ||
| <span v-if="m.recommended" class="ml-1 text-[9px] text-emerald-500 dark:text-emerald-400">recommended</span> | ||
| <span v-if="m.recommended" class="recommend-badge">recommended</span> | ||
| <button | ||
| class="ml-1 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer inline" | ||
| :class="store.isFavorite(p.id, m.id) ? 'text-amber-400 opacity-100' : 'text-zinc-300 dark:text-zinc-600'" | ||
| class="fav-star" | ||
| :class="{ 'is-fav': store.isFavorite(p.id, m.id) }" | ||
| @click.stop="store.toggleFavorite(p.id, m.id)" | ||
| :title="store.isFavorite(p.id, m.id) ? 'Remove from favorites' : 'Add to favorites'" | ||
| >★</button> | ||
| </button> |
There was a problem hiding this comment.
Avoid nested <button> elements in model rows.
The model option row is a button that contains another button for favorites. This is invalid interactive nesting and can cause inconsistent click/keyboard behavior.
Suggested fix
- <button
+ <div
v-for="m in p.models"
:key="m.id"
class="dropdown-item group"
:class="{ active: store.providerName === p.id && store.modelName === m.id }"
`@click`="selectModel(p.id, m.id)"
+ role="button"
+ tabindex="0"
+ `@keydown.enter.prevent`="selectModel(p.id, m.id)"
+ `@keydown.space.prevent`="selectModel(p.id, m.id)"
>
<span class="truncate">{{ m.name || m.id }}</span>
<span v-if="m.recommended" class="recommend-badge">recommended</span>
<button
+ type="button"
class="fav-star"
:class="{ 'is-fav': store.isFavorite(p.id, m.id) }"
`@click.stop`="store.toggleFavorite(p.id, m.id)"
>★</button>
- </button>
+ </div>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <button | |
| v-for="m in p.models" | |
| :key="m.id" | |
| class="w-full px-3 py-1.5 text-xs text-left cursor-pointer select-none transition-colors group" | |
| :class="store.providerName === p.id && store.modelName === m.id | |
| ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10' | |
| : 'text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 hover:text-zinc-700 dark:hover:text-zinc-200'" | |
| class="dropdown-item group" | |
| :class="{ active: store.providerName === p.id && store.modelName === m.id }" | |
| @click="selectModel(p.id, m.id)" | |
| > | |
| <span class="truncate">{{ m.name || m.id }}</span> | |
| <span v-if="m.recommended" class="ml-1 text-[9px] text-emerald-500 dark:text-emerald-400">recommended</span> | |
| <span v-if="m.recommended" class="recommend-badge">recommended</span> | |
| <button | |
| class="ml-1 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer inline" | |
| :class="store.isFavorite(p.id, m.id) ? 'text-amber-400 opacity-100' : 'text-zinc-300 dark:text-zinc-600'" | |
| class="fav-star" | |
| :class="{ 'is-fav': store.isFavorite(p.id, m.id) }" | |
| @click.stop="store.toggleFavorite(p.id, m.id)" | |
| :title="store.isFavorite(p.id, m.id) ? 'Remove from favorites' : 'Add to favorites'" | |
| >★</button> | |
| </button> | |
| <div | |
| v-for="m in p.models" | |
| :key="m.id" | |
| class="dropdown-item group" | |
| :class="{ active: store.providerName === p.id && store.modelName === m.id }" | |
| `@click`="selectModel(p.id, m.id)" | |
| role="button" | |
| tabindex="0" | |
| `@keydown.enter.prevent`="selectModel(p.id, m.id)" | |
| `@keydown.space.prevent`="selectModel(p.id, m.id)" | |
| > | |
| <span class="truncate">{{ m.name || m.id }}</span> | |
| <span v-if="m.recommended" class="recommend-badge">recommended</span> | |
| <button | |
| type="button" | |
| class="fav-star" | |
| :class="{ 'is-fav': store.isFavorite(p.id, m.id) }" | |
| `@click.stop`="store.toggleFavorite(p.id, m.id)" | |
| >★</button> | |
| </div> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/ChatInput.vue` around lines 404 - 418, The model row
currently nests a favorite <button> inside the outer row <button> (the row that
calls selectModel), which is invalid; change the inner favorite control (class
"fav-star") to a non-button interactive element (e.g., a <span> or <div>) with
role="button", tabindex="0", keep the `@click.stop` handler calling
store.toggleFavorite(p.id, m.id), and add keyboard support (handle Enter/Space
via `@keydown`) and the same :class binding using store.isFavorite(p.id, m.id);
alternatively, if you prefer, convert the outer model row element (that calls
selectModel) to a non-button clickable element with role="button" so the
favorite can remain a button — ensure either approach removes nested <button>
tags and preserves selectModel(p.id, m.id), store.toggleFavorite(p.id, m.id),
and accessibility/focus behavior.
| <button class="close-btn" @click="emit('close')"> | ||
| <X :size="14" /> | ||
| </button> |
There was a problem hiding this comment.
Add an accessible name to the close icon button.
The icon-only close button should include an explicit accessible name so screen readers can announce its purpose reliably.
Suggested fix
- <button class="close-btn" `@click`="emit('close')">
+ <button type="button" class="close-btn" aria-label="Close panel" `@click`="emit('close')">
<X :size="14" />
</button>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <button class="close-btn" @click="emit('close')"> | |
| <X :size="14" /> | |
| </button> | |
| <button type="button" class="close-btn" aria-label="Close panel" `@click`="emit('close')"> | |
| <X :size="14" /> | |
| </button> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/RightPanel.vue` around lines 35 - 37, The close button
using the X icon (button with class "close-btn" and click handler emit('close'))
lacks an accessible name; update the button to include an explicit accessible
label (for example add aria-label="Close" or aria-label bound to a localized
string) or include visually-hidden text inside the button so screen readers
announce its purpose, ensuring the label matches your app's localization pattern
if any.
… mode fix, themed bash output
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@web/src/components/ChatInput.vue`:
- Around line 450-461: The channel toggle button (class "channel-btn") lacks
accessible labeling and state; update the button rendered when
store.channelAvailable to include an explicit label for screen readers (e.g.,
aria-label or a visually-hidden span) describing its purpose and add
aria-pressed bound to store.channelEnabled to expose ON/OFF state, and ensure
the click still calls store.toggleChannel(!store.channelEnabled); keep the
existing title if desired but make the accessible name and aria-pressed the
primary mechanism for assistive tech.
In `@web/src/components/TerminalInstance.vue`:
- Around line 89-96: The ptyCreate() call can resolve after the component
unmounts and then set sessionId and call connectWS(), leaving a server PTY
running; to fix, add an "isMounted" (or "alive") boolean/AbortController checked
immediately after awaiting api.ptyCreate() and before assigning sessionId or
calling connectWS() in the block that contains ptyCreate(), and similarly guard
the other async spots (the blocks around lines 133-145 and 165-166) so they
no-op if unmounted; ensure cleanup() clears the flag/aborts the controller and
that connectWS() and any session setup only run when isMounted is true to
prevent post-unmount side-effects.
In `@web/src/components/TerminalPanel.vue`:
- Around line 53-70: Replace the non-focusable span used for closing tabs with a
real button element so keyboard and screen-reader users can activate it: change
the tab-close span inside the tab button to a <button type="button"> that keeps
the `@click.stop`="closeTab(tab.id)" behavior, add an accessible name like
:aria-label="`Close ${tab.label}`" (or aria-label="Close tab") and preserve the
"tab-close" class and SVG child so styling remains; ensure the outer tab button
still responds to clicks for activation (the inner button must use
stopPropagation) and update any references to closeTab in TerminalPanel.vue if
needed.
- Around line 30-40: closeTab() unconditionally changes activeId after removing
a tab, causing the visible terminal to switch when a background tab is closed;
modify closeTab so it first records whether the closed tab was the active one
(compare id to activeId.value), then splice the tab, keep the existing
emit('close') behavior if tabs become empty, and only compute newIdx and assign
activeId.value when the closed tab was active; reference closeTab, tabs.value,
activeId.value, and emit in your change.
In `@web/src/components/ToolCallCard.vue`:
- Around line 56-71: The file/line parsing in the searchResults computed
currently uses /^([^:]+):(\d+):(.*)$/ which fails for file paths containing
colons; change the parsing to match the right-most ":<line>:" pair (e.g. use
/^(.*):(\d+):(.*)$/ or find the last ":<digits>:" via lastIndexOf) inside the
map callback that builds { file, lineNum, content, isRef } so file can contain
colons (Windows drive letters) and lineNum is still parsed as the numeric group;
update the match logic in the searchResults computed accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f4ca57a6-1637-4af8-9c69-a9380f8299a2
📒 Files selected for processing (6)
web/src/components/ChatInput.vueweb/src/components/RightPanel.vueweb/src/components/Sidebar.vueweb/src/components/TerminalInstance.vueweb/src/components/TerminalPanel.vueweb/src/components/ToolCallCard.vue
| <button | ||
| v-if="store.channelAvailable" | ||
| class="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded transition-colors cursor-pointer" | ||
| :class="store.channelEnabled | ||
| ? 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-200 dark:hover:bg-emerald-500/25' | ||
| : 'bg-zinc-100 dark:bg-zinc-700/60 text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-600/60'" | ||
| class="channel-btn" | ||
| :class="{ active: store.channelEnabled }" | ||
| :title="store.channelEnabled ? 'WeChat notifications ON' : 'WeChat notifications OFF'" | ||
| @click="store.toggleChannel(!store.channelEnabled)" | ||
| > | ||
| <svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"> | ||
| <path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" /> | ||
| <path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767A2 2 0 0011 16h2l3 3v-3h1a2 2 0 002-2V9a2 2 0 00-2-2h-2z" /> | ||
| </svg> | ||
| </button> |
There was a problem hiding this comment.
Expose the inline channel toggle state explicitly.
This new icon-only toggle currently relies on title, so screen readers won't reliably get its purpose or on/off state. Add an explicit label and aria-pressed.
Suggested fix
<button
v-if="store.channelAvailable"
+ type="button"
class="channel-btn"
:class="{ active: store.channelEnabled }"
+ :aria-pressed="store.channelEnabled"
+ :aria-label="store.channelEnabled ? 'Disable WeChat notifications' : 'Enable WeChat notifications'"
:title="store.channelEnabled ? 'WeChat notifications ON' : 'WeChat notifications OFF'"
`@click`="store.toggleChannel(!store.channelEnabled)"
>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <button | |
| v-if="store.channelAvailable" | |
| class="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded transition-colors cursor-pointer" | |
| :class="store.channelEnabled | |
| ? 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-200 dark:hover:bg-emerald-500/25' | |
| : 'bg-zinc-100 dark:bg-zinc-700/60 text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-600/60'" | |
| class="channel-btn" | |
| :class="{ active: store.channelEnabled }" | |
| :title="store.channelEnabled ? 'WeChat notifications ON' : 'WeChat notifications OFF'" | |
| @click="store.toggleChannel(!store.channelEnabled)" | |
| > | |
| <svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"> | |
| <path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" /> | |
| <path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767A2 2 0 0011 16h2l3 3v-3h1a2 2 0 002-2V9a2 2 0 00-2-2h-2z" /> | |
| </svg> | |
| </button> | |
| <button | |
| v-if="store.channelAvailable" | |
| type="button" | |
| class="channel-btn" | |
| :class="{ active: store.channelEnabled }" | |
| :aria-pressed="store.channelEnabled" | |
| :aria-label="store.channelEnabled ? 'Disable WeChat notifications' : 'Enable WeChat notifications'" | |
| :title="store.channelEnabled ? 'WeChat notifications ON' : 'WeChat notifications OFF'" | |
| `@click`="store.toggleChannel(!store.channelEnabled)" | |
| > | |
| <svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"> | |
| <path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" /> | |
| <path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767A2 2 0 0011 16h2l3 3v-3h1a2 2 0 002-2V9a2 2 0 00-2-2h-2z" /> | |
| </svg> | |
| </button> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/ChatInput.vue` around lines 450 - 461, The channel toggle
button (class "channel-btn") lacks accessible labeling and state; update the
button rendered when store.channelAvailable to include an explicit label for
screen readers (e.g., aria-label or a visually-hidden span) describing its
purpose and add aria-pressed bound to store.channelEnabled to expose ON/OFF
state, and ensure the click still calls
store.toggleChannel(!store.channelEnabled); keep the existing title if desired
but make the accessible name and aria-pressed the primary mechanism for
assistive tech.
| try { | ||
| const result = await api.ptyCreate() | ||
| sessionId = result.id | ||
| connectWS(result.id) | ||
| } catch (err: unknown) { | ||
| const msg = err instanceof Error ? err.message : String(err) | ||
| term.writeln(`\r\n\x1b[31mFailed to create terminal: ${msg}\x1b[0m`) | ||
| } |
There was a problem hiding this comment.
Guard ptyCreate() against unmount races.
If the component unmounts while api.ptyCreate() is in flight, the promise can still resolve after cleanup(), set sessionId, and leave a PTY running server-side because connectWS() returns once term is gone.
Suggested fix
let ws: WebSocket | null = null
let sessionId = ''
let resizeObserver: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
+let disposed = false
async function init() {
if (!termEl.value) return
+ disposed = false
term = new Terminal({
@@
try {
const result = await api.ptyCreate()
+ if (disposed) {
+ api.ptyKill(result.id).catch(() => {})
+ return
+ }
sessionId = result.id
connectWS(result.id)
} catch (err: unknown) {
@@
function cleanup() {
+ disposed = true
resizeObserver?.disconnect()
resizeObserver = nullAlso applies to: 133-145, 165-166
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/TerminalInstance.vue` around lines 89 - 96, The
ptyCreate() call can resolve after the component unmounts and then set sessionId
and call connectWS(), leaving a server PTY running; to fix, add an "isMounted"
(or "alive") boolean/AbortController checked immediately after awaiting
api.ptyCreate() and before assigning sessionId or calling connectWS() in the
block that contains ptyCreate(), and similarly guard the other async spots (the
blocks around lines 133-145 and 165-166) so they no-op if unmounted; ensure
cleanup() clears the flag/aborts the controller and that connectWS() and any
session setup only run when isMounted is true to prevent post-unmount
side-effects.
| function closeTab(id: string) { | ||
| const idx = tabs.value.findIndex(t => t.id === id) | ||
| if (idx === -1) return | ||
| tabs.value.splice(idx, 1) | ||
| if (tabs.value.length === 0) { | ||
| emit('close') | ||
| return | ||
| } | ||
| if (term) { | ||
| term.dispose() | ||
| term = null | ||
| } | ||
| fitAddon = null | ||
| connected.value = false | ||
| // Select adjacent tab | ||
| const newIdx = Math.min(idx, tabs.value.length - 1) | ||
| activeId.value = tabs.value[newIdx].id |
There was a problem hiding this comment.
Don't change the active terminal when closing a different tab.
closeTab() always selects an adjacent tab after removal, so closing a background tab can unexpectedly switch the visible terminal.
Suggested fix
function closeTab(id: string) {
const idx = tabs.value.findIndex(t => t.id === id)
if (idx === -1) return
+ const wasActive = activeId.value === id
tabs.value.splice(idx, 1)
if (tabs.value.length === 0) {
emit('close')
return
}
- // Select adjacent tab
- const newIdx = Math.min(idx, tabs.value.length - 1)
- activeId.value = tabs.value[newIdx].id
+ if (wasActive) {
+ const newIdx = Math.min(idx, tabs.value.length - 1)
+ activeId.value = tabs.value[newIdx].id
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function closeTab(id: string) { | |
| const idx = tabs.value.findIndex(t => t.id === id) | |
| if (idx === -1) return | |
| tabs.value.splice(idx, 1) | |
| if (tabs.value.length === 0) { | |
| emit('close') | |
| return | |
| } | |
| if (term) { | |
| term.dispose() | |
| term = null | |
| } | |
| fitAddon = null | |
| connected.value = false | |
| // Select adjacent tab | |
| const newIdx = Math.min(idx, tabs.value.length - 1) | |
| activeId.value = tabs.value[newIdx].id | |
| function closeTab(id: string) { | |
| const idx = tabs.value.findIndex(t => t.id === id) | |
| if (idx === -1) return | |
| const wasActive = activeId.value === id | |
| tabs.value.splice(idx, 1) | |
| if (tabs.value.length === 0) { | |
| emit('close') | |
| return | |
| } | |
| if (wasActive) { | |
| const newIdx = Math.min(idx, tabs.value.length - 1) | |
| activeId.value = tabs.value[newIdx].id | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/TerminalPanel.vue` around lines 30 - 40, closeTab()
unconditionally changes activeId after removing a tab, causing the visible
terminal to switch when a background tab is closed; modify closeTab so it first
records whether the closed tab was the active one (compare id to
activeId.value), then splice the tab, keep the existing emit('close') behavior
if tabs become empty, and only compute newIdx and assign activeId.value when the
closed tab was active; reference closeTab, tabs.value, activeId.value, and emit
in your change.
| <button | ||
| v-for="tab in tabs" | ||
| :key="tab.id" | ||
| class="tab-btn" | ||
| :class="{ 'tab-active': tab.id === activeId }" | ||
| @click="activeId = tab.id" | ||
| > | ||
| <span class="tab-label">{{ tab.label }}</span> | ||
| <span | ||
| v-if="tabs.length > 1" | ||
| class="tab-close" | ||
| @click.stop="closeTab(tab.id)" | ||
| > | ||
| <svg class="w-2.5 h-2.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"> | ||
| <path d="M18 6L6 18M6 6l12 12" /> | ||
| </svg> | ||
| </span> | ||
| </button> |
There was a problem hiding this comment.
Make the per-tab close control a real button.
The clickable span isn't focusable or named, so keyboard and screen-reader users can't close individual tabs. This needs to be a separate button, not an ad-hoc click target inside the tab button.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/TerminalPanel.vue` around lines 53 - 70, Replace the
non-focusable span used for closing tabs with a real button element so keyboard
and screen-reader users can activate it: change the tab-close span inside the
tab button to a <button type="button"> that keeps the
`@click.stop`="closeTab(tab.id)" behavior, add an accessible name like
:aria-label="`Close ${tab.label}`" (or aria-label="Close tab") and preserve the
"tab-close" class and SVG child so styling remains; ensure the outer tab button
still responds to clicks for activation (the inner button must use
stopPropagation) and update any references to closeTab in TerminalPanel.vue if
needed.
| const searchResults = computed(() => { | ||
| const output = props.tool.output || '' | ||
| if (!output) return { lines: [], count: null } | ||
| const countMatch = output.match(/\((\d+) (?:matches found|results?)\)/) | ||
| const count = countMatch ? parseInt(countMatch[1]) : null | ||
| const lines = output.split('\n') | ||
| .filter(l => { | ||
| const t = l.trim() | ||
| return t && !t.startsWith('(') | ||
| }) | ||
| .map(line => { | ||
| const m = line.match(/^([^:]+):(\d+):(.*)$/) | ||
| if (m) return { file: m[1], lineNum: parseInt(m[2]), content: m[3], isRef: true } | ||
| return { file: '', lineNum: 0, content: line, isRef: false } | ||
| }) | ||
| return { lines, count } |
There was a problem hiding this comment.
Parse search refs from the right-most :<line>: pair.
The current matcher breaks on paths containing colons, especially Windows drive letters, so valid hits won't render as file references.
Suggested fix
- const m = line.match(/^([^:]+):(\d+):(.*)$/)
+ const m = line.match(/^(.*):(\d+):(.*)$/)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const searchResults = computed(() => { | |
| const output = props.tool.output || '' | |
| if (!output) return { lines: [], count: null } | |
| const countMatch = output.match(/\((\d+) (?:matches found|results?)\)/) | |
| const count = countMatch ? parseInt(countMatch[1]) : null | |
| const lines = output.split('\n') | |
| .filter(l => { | |
| const t = l.trim() | |
| return t && !t.startsWith('(') | |
| }) | |
| .map(line => { | |
| const m = line.match(/^([^:]+):(\d+):(.*)$/) | |
| if (m) return { file: m[1], lineNum: parseInt(m[2]), content: m[3], isRef: true } | |
| return { file: '', lineNum: 0, content: line, isRef: false } | |
| }) | |
| return { lines, count } | |
| const searchResults = computed(() => { | |
| const output = props.tool.output || '' | |
| if (!output) return { lines: [], count: null } | |
| const countMatch = output.match(/\((\d+) (?:matches found|results?)\)/) | |
| const count = countMatch ? parseInt(countMatch[1]) : null | |
| const lines = output.split('\n') | |
| .filter(l => { | |
| const t = l.trim() | |
| return t && !t.startsWith('(') | |
| }) | |
| .map(line => { | |
| const m = line.match(/^(.*):(\d+):(.*)$/) | |
| if (m) return { file: m[1], lineNum: parseInt(m[2]), content: m[3], isRef: true } | |
| return { file: '', lineNum: 0, content: line, isRef: false } | |
| }) | |
| return { lines, count } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/components/ToolCallCard.vue` around lines 56 - 71, The file/line
parsing in the searchResults computed currently uses /^([^:]+):(\d+):(.*)$/
which fails for file paths containing colons; change the parsing to match the
right-most ":<line>:" pair (e.g. use /^(.*):(\d+):(.*)$/ or find the last
":<digits>:" via lastIndexOf) inside the map callback that builds { file,
lineNum, content, isRef } so file can contain colons (Windows drive letters) and
lineNum is still parsed as the numeric group; update the match logic in the
searchResults computed accordingly.
…e, click to collapse
Summary
Complete web frontend redesign based on the Pencil design specification.
Changes
Design System
#FF8400(orange), replacing emerald greenComponents
TopBar.vuewith lucide-vue-next icons (menu, terminal, diff, files, panel toggles)App.vueshell layout with proper sidebar/main/panel structureTheme
dark:Tailwind variants for colors)Design Assets
DESIGN.mdspecification documentdesign/jcode-web.pen,design/landing-page.pen)Testing
npx vite buildpasses (no errors)vue-tsc --noEmitpasses (no type errors)Screenshots
Dark mode and light mode both verified working with new design system.
Summary by CodeRabbit
New Features
Documentation
Style
Chores