feat: adopt shared design system across ClawHub UI (#3026)

* chore: install OpenClaw design system

* feat: adopt shared design system palette

* chore: automate design system updates

* feat: add weekly design system audit

* fix: prevent mobile skills tab overlap

* fix: harden design audit automation

* fix: validate audit changes before execution

* fix: scope design system clone credentials

* fix: align audit with installed design release

* fix: preserve audit artifacts and access

* feat: adopt shared design system on landing page

* fix: align icon geometry with design tokens

* chore: pin design system to v0.0.1

* chore: pin design system to v0.0.1

* chore: pin design system to v0.0.1

* chore: pin design system to v0.0.1

* chore: pin design system to v0.0.1

* fix: migrate ClawHub UI to design tokens

* fix: use public design system installs

* fix: use public design system distribution
This commit is contained in:
Patrick Erichsen
2026-07-09 11:05:59 -07:00
committed by GitHub
parent be22623699
commit de29369ba7
80 changed files with 3110 additions and 569 deletions
+32
View File
@@ -0,0 +1,32 @@
---
name: openclaw-brand
description: Apply OpenClaw visual identity to logos, typography, imagery, voice, documents, presentations, social graphics, and launch materials. Use when the task changes brand identity rather than ordinary product UI or public-page composition.
---
# OpenClaw Brand
Use the canonical identity without turning every surface into a marketing page.
## Workflow
1. Read [identity.md](references/identity.md) for palette, typography, logo, and voice.
2. Read [asset-rights.md](references/asset-rights.md) before copying or redistributing assets.
3. Identify the artifact's audience and whether it is product, documentation, or marketing.
4. Use semantic design tokens when the artifact is code.
5. Keep status colors functional; do not use them as arbitrary decoration.
6. Verify contrast, responsive cropping, and text legibility.
7. Verify logo clearspace only when approved consumer-local guidance defines it;
otherwise report that check as unavailable rather than inventing a measurement.
## Rules
- Use sentence case for headings, labels, buttons, and navigation.
- Let OpenClaw coral carry primary brand emphasis.
- Use sea-glass green as a restrained secondary accent.
- Use neutral ink and warm-paper surfaces as the foundation.
- Prefer Switzer-compatible sans-serif stacks for interface and body copy.
- Reserve Sentient-compatible serif stacks for editorial accents and quotations.
- Use system monospace for code unless the consumer already licenses another face.
- Preserve logo proportions and colors. Do not rotate, distort, outline, or add effects.
- Use real product, interface, community, or mascot imagery when imagery is needed.
- Avoid generic technology gradients, decorative blobs, and ornamental glow as a substitute for content.
@@ -0,0 +1,25 @@
# Asset Rights
This repository distributes CSS and guidance, not brand asset binaries.
## Current Rule
- Do not copy Switzer or Sentient font files into a consumer or release artifact.
- Do not redistribute logos, lobster artwork, mascot files, photos, or
illustrations without a repository-local license or explicit recorded grant.
- A public URL or an asset already committed to a site is not proof of
redistribution rights.
- System and fallback font stacks are always acceptable.
## Before Adding An Asset
Record:
1. source and owner
2. license or written permission
3. allowed uses and redistribution terms
4. required attribution
5. the repository path where that evidence lives
If any item is unknown, keep the asset in the existing consumer and reference it
only from that consumer.
@@ -0,0 +1,42 @@
# OpenClaw Identity
## Color
Use semantic variables in code. The palette names below explain the identity;
they are not permission to replace semantic tokens with raw values.
| Role | Dark | Light |
| --- | --- | --- |
| Page | Ink `#101012` | Warm paper `#f6f5f3` |
| Surface | Ink `#19191c` | Warm paper `#eceae6` |
| Primary text | `#ededed` | `#17171a` |
| Secondary text | `#bcbcc4` | `#46464e` |
| Primary coral | `#f5654a` | `#d84a31` |
| Secondary sea glass | `#4fc8ae` | `#14806e` |
Coral is the primary brand and action color. Sea glass is a secondary accent for
focus, contrast, and occasional supporting emphasis. Neither replaces functional
success, warning, error, or information colors.
## Typography
- Display and body: Switzer when the consumer holds a license, otherwise the
`--oc-font-display` and `--oc-font-body` fallback stack.
- Editorial accent: Sentient when licensed, otherwise `--oc-font-serif`.
- Code: the consumer's licensed monospace or `--oc-font-mono`.
- Use sentence case. Keep headings direct, concrete, and proportional to the
surface that contains them.
## Voice
OpenClaw should sound capable, direct, curious, and human. Prefer plain verbs,
specific nouns, and short explanations. Avoid inflated futurism, vague claims,
and novelty language that obscures what the product does.
## Marks And Imagery
- Preserve the supplied logo's aspect ratio and colors. Apply clearspace only
from approved consumer-local guidance; do not invent a measurement.
- Do not reconstruct the logo from screenshots.
- Use product interfaces, real community work, or approved mascot imagery.
- Keep backgrounds useful to the subject; avoid generic gradients and glow.
@@ -0,0 +1,45 @@
---
name: openclaw-design-audit
description: Audit OpenClaw frontend code and rendered interfaces for design-system drift, token misuse, primitive reimplementation, accessibility problems, responsive defects, and off-brand copy. Use for design reviews, compliance checks, or scheduled audit-and-fix workflows.
---
# OpenClaw Design Audit
Separate mechanical violations from judgment. Report suggestions as suggestions
unless a documented rule makes them violations.
## Workflow
1. Read [rubric.md](references/rubric.md) and run every applicable category.
2. Read the consumer's installed design-system version and current commit SHA.
3. Read the version-matched
[token contract](../openclaw-design-system/references/tokens.md) and
[consumer adapters](../openclaw-design-system/references/consumer-adapters.md).
4. Read the brand or marketing references when those categories apply.
5. Run deterministic source checks before judgment-based review.
6. Inspect representative rendered routes at desktop and mobile sizes.
7. Check light and dark themes where supported.
8. Emit the JSON and Markdown defined in [report-format.md](references/report-format.md).
9. When asked to fix findings, apply only narrow changes allowed by [fix-policy.md](references/fix-policy.md).
10. For scheduled ClawHub delivery, follow [github-pr-delivery.md](references/github-pr-delivery.md).
## Evidence
Each finding must include:
- file and line
- category and severity
- stable rule ID
- concise remediation
- design-system reference
- whether the finding is mechanical or judgment-based
## Curation
- Include every error.
- Rank warnings before informational findings, then by affected-file count.
- Surface at most five non-error findings in the concise report.
- Summarize remaining non-error findings by count.
- Treat zero errors, zero warnings, and five or fewer informational findings as
no significant drift.
- Never invent source locations or visual evidence.
@@ -0,0 +1,25 @@
# Audit Fix Policy
An audit may automatically fix a finding only when the change is narrow,
deterministic, and covered by an existing rule.
## Allowed
- replace a raw value with an equivalent canonical semantic token
- replace a new legacy alias with its canonical token
- use an established local primitive instead of a duplicate raw control
- add a missing accessible label when intent is unambiguous
- repair clipping or overflow without changing information architecture
- update the pinned design-system tag in a dedicated dependency change
## Requires Human Review
- copy, hierarchy, navigation, or information-architecture changes
- new components or abstractions
- broad visual redesign
- deletion of compatibility aliases
- asset or license interpretation
- changes that intentionally alter current rendered behavior
Do not combine unrelated dependency, redesign, and audit fixes in one pull
request. Preserve tests and include real browser evidence for rendered changes.
@@ -0,0 +1,55 @@
# GitHub Pull Request Delivery
The scheduled ClawHub audit opens a pull request directly against
`openclaw/clawhub`. It does not create or update a tracker issue.
The schedule and credentials live in the consumer repository's GitHub Actions
workflow. This design-system skill defines the audit and delivery contract; it
does not schedule itself.
## Branch And Scope
- Use a stable automation branch such as `automation/design-audit`.
- Start from current remote `main`.
- Commit only the report and allowed deterministic fixes.
- Do not overwrite unrelated human work on an existing branch.
## Procedure
1. Checkout `openclaw/clawhub` with full history and fetch remote `main`.
2. Reset only the dedicated automation branch to `origin/main`.
3. Install the design system at the workflow's pinned Git tag.
4. Run source checks, browser checks, and report generation.
5. Apply only fixes allowed by `fix-policy.md`.
6. Write reports under the consumer's established audit-artifact path.
7. If the decision table says `artifact only`, upload the reports and job
summary without pushing a branch.
8. Otherwise commit, force-push the dedicated automation branch with
`--force-with-lease`, then use `gh pr create` or `gh pr edit` for the single
open pull request owned by that branch.
## Pull Request
The title must identify the audit and date. The body includes:
- design-system version
- audited ClawHub SHA
- count by severity
- commands and routes checked
- concise expanded findings
- whether fixes are included
- paths to JSON, Markdown, and screenshot artifacts
If an open audit pull request exists, update it only when it owns the same stable
automation branch. Close it without merge when a later clean run makes its
findings obsolete.
## Decision Table
| Findings | Delivery |
| --- | --- |
| One or more errors | Open or update the pull request |
| Zero errors and one or more warnings | Open or update the pull request |
| Zero errors, zero warnings, more than five informational findings | Open or update the pull request |
| Zero errors, zero warnings, five or fewer informational findings | Artifact and job summary only |
| No findings | Artifact and job summary only; close an obsolete open audit PR |
@@ -0,0 +1,46 @@
# Audit Report Format
Produce both `design-audit.json` and `design-audit.md`.
## JSON
```json
{
"designSystemVersion": "v0.0.1",
"consumerSha": "<sha>",
"summary": {
"errors": 0,
"warnings": 0,
"info": 0
},
"findings": [
{
"id": "token/raw-color",
"severity": "warning",
"kind": "mechanical",
"file": "src/example.css",
"line": 12,
"message": "Use the semantic accent token.",
"remediation": "Replace the raw coral value with var(--oc-accent-primary).",
"reference": "openclaw-design-system/references/tokens.md"
}
]
}
```
Sort findings by severity, rule ID, file, then line. Keep stable IDs so recurring
automation can compare runs.
## Markdown
Include:
1. audited design-system version and consumer SHA
2. validation commands and rendered routes
3. count by severity
4. every error
5. at most five warning or informational findings
6. count of additional non-error findings not expanded
Use repository-relative file links. State explicitly when no significant drift
was found.
@@ -0,0 +1,37 @@
# Design Audit Rubric
## Mechanical Rules
| ID | Check |
| --- | --- |
| `token/raw-color` | New raw colors where a semantic token exists |
| `token/undefined` | Custom properties used but not defined by package or consumer |
| `token/legacy-alias` | New code depends on a migration-only alias |
| `component/duplicate` | Raw control or primitive duplicates an established local primitive |
| `component/state` | Missing hover, focus, disabled, loading, invalid, or selected state |
| `layout/overflow` | Text or fixed-format UI clips or causes accidental horizontal scroll |
| `a11y/name` | Interactive control lacks an accessible name |
| `a11y/focus` | Keyboard focus is hidden, trapped, or incoherent |
| `theme/parity` | Light or dark theme loses content, hierarchy, or contrast |
| `asset/rights` | New distributable asset has no recorded rights |
## Judgment Checks
| ID | Check |
| --- | --- |
| `hierarchy/primary-action` | Competing primary actions obscure the decision |
| `layout/card-overuse` | Sections or cards are unnecessarily nested or floated |
| `typography/scale` | Type scale does not match its container or task density |
| `brand/accent` | Coral, sea glass, or status colors are used without their intended role |
| `marketing/subject` | First viewport hides the actual product, place, person, or offer |
| `copy/clarity` | Interface text is vague, inflated, or does not name the action |
## Severity
- `error`: broken interaction, accessibility barrier, illegible theme, accidental
overflow, missing asset rights, or deterministic contract violation.
- `warning`: likely drift or inconsistency with meaningful user impact.
- `info`: improvement with limited current impact.
Do not mark aesthetic preference as a violation. A finding needs source or
rendered evidence and a documented rule.
@@ -0,0 +1,41 @@
---
name: openclaw-design-system
description: Build or modify OpenClaw application UI using canonical semantic tokens, themes, shared CSS foundations, consumer adapters, and established local primitives. Use for product interfaces, component styling, theme work, or design-token integration.
---
# OpenClaw Design System
Use the shared package for foundations and framework-neutral visual primitives.
Keep consumer-specific behavior, data, routes, and layout composition local.
## Workflow
1. Read [tokens.md](references/tokens.md) before choosing colors, spacing, type, radii, or shadows.
2. Read [consumer-adapters.md](references/consumer-adapters.md) for the current framework.
3. Inspect the consumer's existing shared primitives before creating a component.
4. Use semantic tokens for UI intent; use palette primitives only for documented exceptions.
5. Keep application behavior, routes, and information architecture unchanged unless the task says otherwise.
6. Validate the affected routes with existing tests and real browser screenshots.
## Interface Rules
- Import the complete CSS contract or its focused exported entry points.
- Compose shared classes from `components.css` before adding a one-off visual implementation.
- Use local shared primitives before raw controls or one-off component implementations.
- Keep one primary action per decision area.
- Use familiar icons for icon-only commands and provide accessible names.
- Use status colors for status, warning, success, error, and informational meaning.
- Keep cards, controls, and repeated fixed-format elements dimensionally stable.
- Avoid nested decorative cards and page sections styled as floating cards.
- Keep surfaces, controls, and insets square through their semantic radius tokens.
- Reserve round geometry for avatars, status dots, and other truly circular indicators.
- Keep focus, hover, active, disabled, loading, and invalid states coherent.
- Keep text within its container at supported viewport sizes.
- Prefer dense, scan-friendly composition for operational product surfaces.
## Ownership
Move visual implementation into this repository when its interface is
framework-neutral and useful across consumers. Keep runtime behavior and
framework adapters local until at least two consumers need the same interface
and behavior.
@@ -0,0 +1,55 @@
# Consumer Adapters
## Plain CSS And Astro
Use the complete contract when the global reset is desired:
```css
@import "@openclaw/design-system";
```
For a controlled migration, import `tokens.css`, `themes.css`, and
`typography.css`, then `components.css`. Retain consumer-specific layout CSS.
Theme switching remains application-owned. The canonical public-site selector is
`html[data-theme="light"|"dark"]`.
## Tailwind 4
Import in this order:
```css
@import "@openclaw/design-system/tokens.css";
@import "@openclaw/design-system/themes.css";
@import "@openclaw/design-system/typography.css";
@import "@openclaw/design-system/components.css";
@import "@openclaw/design-system/themes/product.css";
@import "@openclaw/design-system/compat/clawhub.css";
@import "@openclaw/design-system/tailwind.css";
```
The Tailwind adapter exposes theme utilities. `components.css` provides
framework-neutral classes; keep Radix, React, route, and product behavior in the
consumer.
The ClawHub compatibility adapter understands:
- `data-theme-family="claw"`
- `data-theme-resolved="light"|"dark"`
- `data-theme-mode="system"`
- the existing unprefixed token aliases
Remove aliases only after source search and browser validation prove that no
consumer uses them.
## Static Documentation Builders
Copy or resolve the focused CSS exports as build inputs. Import tokens, themes,
and typography before the docs shell CSS. Do not import `base.css` until the
generated navigation, prose, search, code, and Mermaid views have been compared
in a real browser.
## Versioning
Install an immutable Git tag. Runtime CSS and skill guidance use the same tag.
Dependabot or a scheduled update workflow may propose a newer tag, but migration
and visual validation remain consumer responsibilities.
@@ -0,0 +1,51 @@
# Token Contract
Import `@openclaw/design-system` for the complete foundation or use focused
exports when the consumer must control reset and adapter order.
## Layers
| Layer | Prefix | Purpose |
| --- | --- | --- |
| Palette | `--oc-palette-*` | Fixed source colors; rare direct use |
| Semantic | `--oc-bg-*`, `--oc-text-*`, `--oc-accent-*` | Theme-aware UI intent |
| Scale | `--oc-space-*`, `--oc-font-size-*`, `--oc-radius-*` | Shared dimensions |
| Motion | `--oc-duration-*`, `--oc-ease-*` | Shared interaction timing |
| Product | `--oc-status-*`, `--oc-input-*`, `--oc-diff-*` | Opt-in operational UI |
| Consumer alias | Unprefixed legacy names | Migration compatibility only |
## Semantic Choices
- Page background: `--oc-bg-page`
- Ordinary surface: `--oc-bg-surface`
- Elevated surface: `--oc-bg-elevated`
- Primary, secondary, muted text: `--oc-text-primary`,
`--oc-text-secondary`, `--oc-text-muted`
- Primary action: `--oc-accent-primary`; hover:
`--oc-accent-primary-hover`
- Secondary accent: `--oc-accent-secondary`
- Subtle and accent borders: `--oc-border-subtle`,
`--oc-border-accent`
- Focus: `--oc-focus-ring`
Use `color-mix()` from semantic variables for a local translucent state. Add a
new shared semantic token only when the same intent recurs across consumers.
## Radius
Use semantic geometry roles in product UI:
- `--oc-radius-surface`: cards, panels, and framed sections
- `--oc-radius-control`: buttons, fields, chips, and segmented controls
- `--oc-radius-inset`: nested interactive or decorative surfaces
- `--oc-radius-round`: avatars, status dots, and genuinely circular indicators
The first three roles are square in the canonical OpenClaw system. Raw
`--oc-radius-*` scale values remain available for documented exceptions, but
must not replace the semantic defaults.
## Ownership
Consumer repositories own page composition and application states. This package
owns stable visual foundations, framework-neutral component primitives, and
thin migration aliases.
+31
View File
@@ -0,0 +1,31 @@
---
name: openclaw-design
description: Route OpenClaw design work to the canonical brand, product design-system, marketing-page, or design-audit guidance. Use when a task touches OpenClaw visual identity, shared CSS tokens, product UI, public web pages, or design-system compliance.
---
# OpenClaw Design
Choose one focused branch before changing an interface. Load multiple branches
only when the task genuinely crosses them.
| Skill | Use for |
| --- | --- |
| `openclaw-brand` | Identity decisions, typography, logos, imagery, voice, and non-product brand artifacts |
| `openclaw-design-system` | Application UI, semantic tokens, themes, component reuse, and framework adapters |
| `openclaw-marketing-pages` | Public-page composition, landing/content pages, navigation, SEO, and responsive layout |
| `openclaw-design-audit` | Design drift, token misuse, component substitution, accessibility, and recurring audits |
For a public website change, start with `openclaw-marketing-pages` and add
`openclaw-brand` only when the task changes identity, logo, imagery, typography,
or voice. For a product application, start with `openclaw-design-system`.
## Shared Contract
- Treat this repository's release tag as the version of both runtime CSS and agent guidance.
- Prefer semantic tokens over raw palette values.
- Keep product-specific components and layouts in their consumer repository.
- Add shared implementation only after at least two consumers demonstrate the same interface.
- Preserve consumer behavior while changing the visual foundation.
- Validate rendered pages in a real browser at desktop and mobile sizes.
- Check both light and dark themes where the consumer supports them.
- Do not redistribute fonts, logos, or artwork without recorded permission.
@@ -0,0 +1,32 @@
---
name: openclaw-marketing-pages
description: Create or update OpenClaw public-page composition for websites, landing pages, content pages, ecosystem pages, and campaigns. Use for page structure, navigation, responsive layout, media, SEO presentation, or public-site visual polish.
---
# OpenClaw Marketing Pages
Build the real public experience first. Do not replace it with a generic landing-page
template or an explanatory feature tour.
## Workflow
1. Read [page-patterns.md](references/page-patterns.md).
2. Inspect the consumer's existing header, footer, sections, and page primitives.
3. Reuse local public-site patterns before adding another composition.
4. Use the canonical tokens and typography contract.
5. Make the brand, product, place, or object visible in the first viewport.
6. Keep a hint of the next section visible at common desktop and mobile heights.
7. Validate navigation, content hierarchy, media, light/dark themes, and responsive behavior in a browser.
## Rules
- Use the literal brand, product, place, person, or offer as the primary headline.
- Put value propositions in supporting copy rather than an abstract headline.
- Use relevant product or community imagery instead of atmospheric stock media.
- Keep page sections unframed; use cards only for genuinely repeated items.
- Avoid repetitive social-proof, feature-grid, and CTA sections without a content need.
- Keep headings proportionate to their container.
- Use icons for tools and familiar actions; do not use decorative icon boxes.
- Preserve readable line length and clear section rhythm.
- Use motion to explain state or progression, not as ambient decoration.
- Preserve SEO metadata, semantic heading order, and accessible navigation.
@@ -0,0 +1,36 @@
# Public Page Patterns
## First Viewport
- Show the literal product, project, venue, person, or offer immediately.
- Use the name or literal category as the primary heading.
- Keep supporting copy specific: what it is, who it helps, and the next action.
- Let the next section remain partially visible at common viewport heights.
- Use an approved real image or an actual product state when media helps.
## Structure
- Reuse the consumer's header, footer, content width, and navigation behavior.
- Build sections as full-width bands or unframed layouts with constrained inner
content.
- Use cards for repeated comparable items, not as wrappers around every section.
- Keep one primary action per decision area and avoid repeated CTA blocks.
- Preserve semantic heading order, metadata, canonical URLs, and readable prose
widths.
## Responsive Checks
Check at least one narrow mobile viewport and one desktop viewport. Verify:
- navigation and menus remain reachable
- headings and buttons wrap without clipping
- media shows the important subject
- fixed controls do not cover content
- horizontal scrolling is intentional
- light and dark themes preserve hierarchy and contrast
## Local Ownership
Marketing layouts, decorative textures, film grain, dot grids, site headers,
footers, article prose, and route-specific media remain in the consumer unless
multiple sites prove the same reusable interface.
+2 -4
View File
@@ -3,8 +3,7 @@ updates:
- package-ecosystem: "bun"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
interval: "daily"
time: "09:00"
timezone: "America/Los_Angeles"
# Preserve the old total Bun capacity: 10 general updates plus the
@@ -37,8 +36,7 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
interval: "daily"
time: "09:00"
timezone: "America/Los_Angeles"
groups:
+271
View File
@@ -0,0 +1,271 @@
name: Weekly Design System Audit
on:
schedule:
# Monday at 08:17 Pacific during daylight saving time. GitHub cron uses UTC.
- cron: "17 15 * * 1"
workflow_dispatch:
permissions: {}
concurrency:
group: clawhub-design-system-audit
cancel-in-progress: false
env:
AUDIT_BRANCH: automation/design-system-audit
ARTIFACT_DIRECTORY: artifacts/design-audit
BUN_VERSION: "1.3.11"
CODEX_VERSION: "0.142.3"
jobs:
audit:
runs-on: ubuntu-latest
timeout-minutes: 75
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout current main
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
ref: main
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Install browser
run: bunx playwright install --with-deps chromium
- name: Resolve installed design-system release
id: release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
release="v$(node -p "require('./node_modules/@openclaw/design-system/package.json').version")"
[[ "$release" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
gh api "repos/openclaw/design-system/releases/tags/${release}" >/dev/null
echo "tag=$release" >> "$GITHUB_OUTPUT"
mkdir -p "$ARTIFACT_DIRECTORY"
git clone \
--branch "$release" \
--depth 1 \
https://github.com/openclaw/design-system.git \
"$ARTIFACT_DIRECTORY/design-system"
- name: Record audit commits
id: commits
run: |
set -euo pipefail
consumer_sha="$(git rev-parse HEAD)"
base_sha="$(git rev-list -1 --before='8 days ago' origin/main || true)"
if [[ -z "$base_sha" ]]; then
base_sha="$(git rev-list --max-parents=0 HEAD | tail -n 1)"
fi
echo "consumer_sha=$consumer_sha" >> "$GITHUB_OUTPUT"
echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"
{
echo "### Design audit inputs"
echo
echo "- ClawHub SHA: \`$consumer_sha\`"
echo "- Comparison base: \`$base_sha\`"
echo "- Design system: \`${{ steps.release.outputs.tag }}\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Run deterministic source checks
run: |
mkdir -p "$ARTIFACT_DIRECTORY"
bun scripts/design-audit/source-check.ts \
--base "${{ steps.commits.outputs.base_sha }}" \
--design-system-styles "$ARTIFACT_DIRECTORY/design-system/styles" \
--output "$ARTIFACT_DIRECTORY/deterministic.json"
- name: Build ClawHub
env:
VITE_CONVEX_SITE_URL: https://wry-manatee-359.convex.site
VITE_CONVEX_URL: https://wry-manatee-359.convex.cloud
run: bun run build
- name: Capture real-browser evidence
env:
VITE_CONVEX_SITE_URL: https://wry-manatee-359.convex.site
VITE_CONVEX_URL: https://wry-manatee-359.convex.cloud
run: |
set -euo pipefail
HOST=127.0.0.1 PORT=4173 bun .output/server/index.mjs \
> "$ARTIFACT_DIRECTORY/preview.log" 2>&1 &
preview_pid=$!
trap 'kill "$preview_pid" 2>/dev/null || true' EXIT
for attempt in $(seq 1 60); do
if curl --fail --silent http://127.0.0.1:4173/favicon.ico >/dev/null; then
break
fi
if [[ "$attempt" == "60" ]]; then
cat "$ARTIFACT_DIRECTORY/preview.log"
exit 1
fi
sleep 1
done
bun scripts/design-audit/browser-check.ts \
--base-url http://127.0.0.1:4173 \
--output "$ARTIFACT_DIRECTORY/browser-evidence.json" \
--screenshots "$ARTIFACT_DIRECTORY/screenshots"
- name: Install Codex CLI
run: |
set -euo pipefail
npm install -g "@openai/codex@${CODEX_VERSION}"
codex --version
- name: Authenticate Codex CLI
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: printf '%s' "$OPENAI_API_KEY" | codex login --with-api-key
- name: Run judgment audit and safe fixes
id: codex
continue-on-error: true
run: |
bun scripts/design-audit/run-codex.ts \
--deterministic "$ARTIFACT_DIRECTORY/deterministic.json" \
--browser "$ARTIFACT_DIRECTORY/browser-evidence.json" \
--screenshots "$ARTIFACT_DIRECTORY/screenshots" \
--design-system "$ARTIFACT_DIRECTORY/design-system" \
--output "$ARTIFACT_DIRECTORY/codex-report.json" \
--events "$ARTIFACT_DIRECTORY/codex-events.jsonl"
- name: Validate agent change boundary
id: change_boundary
if: steps.codex.outcome == 'success'
continue-on-error: true
run: bun scripts/design-audit/validate-changes.ts
- name: Re-run deterministic checks on agent patch
id: post_source
if: steps.codex.outcome == 'success' && steps.change_boundary.outcome == 'success'
continue-on-error: true
run: |
bun scripts/design-audit/source-check.ts \
--working-tree \
--fail-on-findings \
--design-system-styles "$ARTIFACT_DIRECTORY/design-system/styles" \
--output "$ARTIFACT_DIRECTORY/post-agent-deterministic.json"
- name: Validate proposed source fixes
id: validation
if: steps.codex.outcome == 'success' && steps.change_boundary.outcome == 'success' && steps.post_source.outcome == 'success'
continue-on-error: true
env:
VITE_CONVEX_SITE_URL: https://wry-manatee-359.convex.site
VITE_CONVEX_URL: https://wry-manatee-359.convex.cloud
run: |
set -euo pipefail
{
bun run test:ui-contract
bun run ci:static
bun run ci:unit
bun run ci:types-build
bun run ci:playwright-smoke
} 2>&1 | tee "$ARTIFACT_DIRECTORY/validation.log"
- name: Finalize reports
id: finalize
if: steps.codex.outcome == 'success' && steps.change_boundary.outcome == 'success' && steps.post_source.outcome == 'success' && !cancelled()
run: |
bun scripts/design-audit/finalize.ts \
--deterministic "$ARTIFACT_DIRECTORY/deterministic.json" \
--codex "$ARTIFACT_DIRECTORY/codex-report.json" \
--browser "$ARTIFACT_DIRECTORY/browser-evidence.json" \
--output-directory "$ARTIFACT_DIRECTORY" \
--release "${{ steps.release.outputs.tag }}" \
--base-sha "${{ steps.commits.outputs.base_sha }}" \
--consumer-sha "${{ steps.commits.outputs.consumer_sha }}" \
--validation-passed "${{ steps.validation.outcome == 'success' }}" \
--run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
- name: Commit audit branch
if: steps.finalize.outputs.open_pr == 'true'
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$AUDIT_BRANCH"
git add src design-audits/latest
git commit -m "chore: apply weekly design audit"
git push --force-with-lease origin "HEAD:$AUDIT_BRANCH"
- name: Open or update draft pull request
if: steps.finalize.outputs.open_pr == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
open_pr="$(
gh pr list \
--repo "$GITHUB_REPOSITORY" \
--state open \
--head "$AUDIT_BRANCH" \
--base main \
--json number \
--jq '.[0].number'
)"
title="[automation] Weekly design audit $(date -u +%F)"
if [[ -n "$open_pr" ]]; then
gh pr edit "$open_pr" \
--repo "$GITHUB_REPOSITORY" \
--title "$title" \
--body-file "$ARTIFACT_DIRECTORY/pull-request-body.md"
gh pr ready "$open_pr" --repo "$GITHUB_REPOSITORY" --undo || true
else
gh pr create \
--repo "$GITHUB_REPOSITORY" \
--base main \
--head "$AUDIT_BRANCH" \
--title "$title" \
--body-file "$ARTIFACT_DIRECTORY/pull-request-body.md" \
--draft
fi
- name: Close obsolete clean audit pull request
if: steps.validation.outcome == 'success' && steps.finalize.outputs.open_pr != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
open_pr="$(
gh pr list \
--repo "$GITHUB_REPOSITORY" \
--state open \
--head "$AUDIT_BRANCH" \
--base main \
--json number \
--jq '.[0].number'
)"
if [[ -n "$open_pr" ]]; then
gh pr close "$open_pr" \
--repo "$GITHUB_REPOSITORY" \
--comment "Closing without merge: the latest validated audit found no significant drift."
fi
- name: Upload audit artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: design-system-audit-${{ github.run_id }}
path: ${{ env.ARTIFACT_DIRECTORY }}
if-no-files-found: warn
retention-days: 30
- name: Fail unsuccessful audit
if: steps.codex.outcome == 'failure' || steps.change_boundary.outcome == 'failure' || steps.post_source.outcome == 'failure' || steps.validation.outcome == 'failure'
run: |
echo "The audit or validation failed. Artifacts were preserved and no PR was created." >&2
exit 1
@@ -0,0 +1,117 @@
name: Update design system skills
on:
schedule:
- cron: "17 15 * * *"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: update-design-system-skills
cancel-in-progress: false
jobs:
update:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- name: Resolve latest design-system release
id: release
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="$(gh api repos/openclaw/design-system/releases/latest --jq .tag_name)"
test -n "$tag"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Reinstall released design skills
env:
DESIGN_SYSTEM_TAG: ${{ steps.release.outputs.tag }}
run: |
npm exec --yes --package=skills@1.5.15 -- skills add \
"https://github.com/openclaw/design-system.git#${DESIGN_SYSTEM_TAG}" \
--skill "*" \
--agent codex \
--copy \
--yes \
--full-depth
- name: Validate and stage expected changes
id: changes
run: |
allowed='^(\.agents/skills/openclaw-(brand|design|design-audit|design-system|marketing-pages)(/|$)|skills-lock\.json)$'
unexpected="$(
git status --short --untracked-files=all \
| sed -E 's/^.. //' \
| grep -Ev "$allowed" \
|| true
)"
if [[ -n "$unexpected" ]]; then
echo "Unexpected paths changed:"
echo "$unexpected"
exit 1
fi
git add \
.agents/skills/openclaw-brand \
.agents/skills/openclaw-design \
.agents/skills/openclaw-design-audit \
.agents/skills/openclaw-design-system \
.agents/skills/openclaw-marketing-pages \
skills-lock.json
if git diff --cached --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Open or update pull request
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
DESIGN_SYSTEM_TAG: ${{ steps.release.outputs.tag }}
run: |
branch="automation/design-system-skills"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin "refs/heads/${branch}:refs/remotes/origin/${branch}" || true
git switch -C "$branch"
git commit -m "chore: update design system skills to ${DESIGN_SYSTEM_TAG}"
git push --force-with-lease origin "$branch"
body="$RUNNER_TEMP/design-system-skills-pr.md"
{
echo "## Summary"
echo
echo "- refresh committed OpenClaw design skills from release ${DESIGN_SYSTEM_TAG}"
echo "- update only the five design-system skill directories and skills-lock.json"
echo
echo "Generated by the daily design-system skill updater."
} > "$body"
number="$(
gh pr list \
--head "$branch" \
--state open \
--json number \
--jq '.[0].number // empty'
)"
if [[ -n "$number" ]]; then
gh pr edit "$number" \
--title "chore: update design system skills to ${DESIGN_SYSTEM_TAG}" \
--body-file "$body"
else
gh pr create \
--base main \
--head "$branch" \
--title "chore: update design system skills to ${DESIGN_SYSTEM_TAG}" \
--body-file "$body"
fi
+1
View File
@@ -27,6 +27,7 @@
"coverage/",
"dist/",
"node_modules/",
".agents/skills/openclaw-*/**",
"public/",
"src/routeTree.gen.ts",
"test-results/",
+3
View File
@@ -14,6 +14,7 @@
"@fontsource/manrope": "5.2.8",
"@fontsource/noto-sans-sc": "5.2.9",
"@monaco-editor/react": "4.7.0",
"@openclaw/design-system": "git+https://github.com/openclaw/design-system.git#v0.0.1",
"@openclaw/plugin-inspector": "0.3.17",
"@radix-ui/react-avatar": "1.2.2",
"@radix-ui/react-dialog": "1.1.19",
@@ -428,6 +429,8 @@
"@openclaw/clawhub-admin": ["@openclaw/clawhub-admin@workspace:packages/clawhub-admin"],
"@openclaw/design-system": ["@openclaw/design-system@github:openclaw/design-system#b1774c3", {}, "openclaw-design-system-b1774c3", "sha512-MDWcUclOCIb2eX8MAxQfZoVIDdKTwo5qM084NR0bnKPmuNiNdzcIT+GpWGt9e/VUHFhJFttoL8cLlQN5eHjjnw=="],
"@openclaw/plugin-inspector": ["@openclaw/plugin-inspector@0.3.17", "", { "bin": { "plugin-inspector": "src/cli.js" } }, "sha512-JPPHPhiXMsIvrV8UR8RQjhflMjRZX/uIhy9meE81dup7MMSnRJcsTGOXYACohv6e4z2P95z2QuE7nZkWT6Ysuw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+47
View File
@@ -0,0 +1,47 @@
{
"designSystemVersion": "v0.0.1",
"consumerSha": "274c5b71ef2abef87dcf7442a086629a6ea71992",
"auditBaseSha": "735e1c4d",
"generatedAt": "2026-07-08T23:59:54.797Z",
"summary": {
"errors": 0,
"warnings": 1,
"info": 0
},
"findings": [
{
"id": "layout/overflow",
"severity": "warning",
"kind": "mechanical",
"file": "src/styles.css",
"line": 29470,
"message": "Mobile /skills used the shared equal-width browse-tab rule with min-width: 0; in the supplied light/dark mobile screenshots, “Most starred” visually runs into “Featured”.",
"remediation": "Fixed by adding a skills-browse-page route class and route-scoped mobile CSS so skills tabs size to their content within the existing horizontal-scroll tab strip.",
"reference": "openclaw-design-audit/references/rubric.md layout/overflow; openclaw-marketing-pages/references/page-patterns.md Responsive Checks"
}
],
"changedFiles": ["src/routes/skills/index.tsx", "src/styles.css"],
"validationCommands": [
"bun run test:ui-contract",
"bun run ci:static",
"bun run ci:unit",
"bun run ci:types-build",
"bun run ci:playwright-smoke"
],
"renderedRoutes": ["/", "/skills", "/plugins"],
"screenshotPaths": [
".artifacts/design-audit-local/post-fix/screenshots/home-dark-desktop.png",
".artifacts/design-audit-local/post-fix/screenshots/skills-dark-desktop.png",
".artifacts/design-audit-local/post-fix/screenshots/plugins-dark-desktop.png",
".artifacts/design-audit-local/post-fix/screenshots/home-light-desktop.png",
".artifacts/design-audit-local/post-fix/screenshots/skills-light-desktop.png",
".artifacts/design-audit-local/post-fix/screenshots/plugins-light-desktop.png",
".artifacts/design-audit-local/post-fix/screenshots/home-dark-mobile.png",
".artifacts/design-audit-local/post-fix/screenshots/skills-dark-mobile.png",
".artifacts/design-audit-local/post-fix/screenshots/plugins-dark-mobile.png",
".artifacts/design-audit-local/post-fix/screenshots/home-light-mobile.png",
".artifacts/design-audit-local/post-fix/screenshots/skills-light-mobile.png",
".artifacts/design-audit-local/post-fix/screenshots/plugins-light-mobile.png"
],
"validationPassed": true
}
+38
View File
@@ -0,0 +1,38 @@
# ClawHub design audit
- Design system: `v0.0.1`
- ClawHub commit: `274c5b71ef2abef87dcf7442a086629a6ea71992`
- Comparison base: `735e1c4d`
- Generated: 2026-07-08T23:59:54.797Z
- Validation: passed
## Summary
- Errors: 0
- Warnings: 1
- Informational: 0
- Safe source fixes: 2
## Validation
- `bun run test:ui-contract`
- `bun run ci:static`
- `bun run ci:unit`
- `bun run ci:types-build`
- `bun run ci:playwright-smoke`
## Rendered routes
- `/`
- `/skills`
- `/plugins`
## Findings
### WARNING: `layout/overflow`
- Evidence: [src/styles.css](../../src/styles.css#L29470)
- Kind: mechanical
- Finding: Mobile /skills used the shared equal-width browse-tab rule with min-width: 0; in the supplied light/dark mobile screenshots, “Most starred” visually runs into “Featured”.
- Remediation: Fixed by adding a skills-browse-page route class and route-scoped mobile CSS so skills tabs size to their content within the existing horizontal-scroll tab strip.
- Contract: `openclaw-design-audit/references/rubric.md layout/overflow; openclaw-marketing-pages/references/page-patterns.md Responsive Checks`
+6
View File
@@ -36,6 +36,10 @@
"deadcode:exports": "KNIP_INCLUDE_TESTS=1 bunx knip@6.8.0 --config knip.config.ts --no-progress --reporter compact --exports --no-config-hints",
"deadcode:files": "bunx knip@6.8.0 --config knip.config.ts --production --no-progress --reporter compact --files --no-config-hints",
"deadcode:knip": "bun run deadcode:files && bun run deadcode:dependencies && bun run deadcode:exports",
"design:audit:browser": "bun scripts/design-audit/browser-check.ts",
"design:audit:codex": "bun scripts/design-audit/run-codex.ts",
"design:audit:finalize": "bun scripts/design-audit/finalize.ts",
"design:audit:source": "bun scripts/design-audit/source-check.ts",
"dev": "bun run llms:generate && bun --bun vite dev --port 3000",
"dev:workers": "bun scripts/dev-workers.ts",
"dev:worktree": "wt --yes dev",
@@ -62,6 +66,7 @@
"seed:public-corpus": "bun run setup:worktree -- --quiet && bunx convex dev --once --typecheck=disable && bun scripts/public-corpus/seed-public-corpus.ts",
"setup:worktree": "bun scripts/setup-worktree.ts",
"skill-cards:worker": "bun scripts/skill-cards/run-skill-card-worker.ts",
"skills:install": "npm exec --yes --package=skills@1.5.15 -- skills add 'openclaw/design-system#v0.0.1' --skill '*' --agent codex --copy --yes --full-depth",
"test": "vitest run",
"test:e2e": "vitest run -c vitest.e2e.config.ts",
"test:e2e:local": "bash scripts/run-playwright-local.sh",
@@ -84,6 +89,7 @@
"@fontsource/manrope": "5.2.8",
"@fontsource/noto-sans-sc": "5.2.9",
"@monaco-editor/react": "4.7.0",
"@openclaw/design-system": "git+https://github.com/openclaw/design-system.git#v0.0.1",
"@openclaw/plugin-inspector": "0.3.17",
"@radix-ui/react-avatar": "1.2.2",
"@radix-ui/react-dialog": "1.1.19",
+147
View File
@@ -0,0 +1,147 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { chromium, type BrowserContext } from "@playwright/test";
type BrowserEvidence = {
route: string;
theme: "dark" | "light";
viewport: "desktop" | "mobile";
screenshot: string;
horizontalOverflow: number;
unnamedInteractiveElements: string[];
pageErrors: string[];
};
const routes = ["/", "/skills", "/plugins"];
const viewports = {
desktop: { width: 1440, height: 900 },
mobile: { width: 390, height: 844 },
} as const;
function argument(name: string) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
async function setTheme(context: BrowserContext, theme: "dark" | "light") {
await context.addInitScript((resolvedTheme) => {
window.localStorage.setItem(
"clawhub-theme-selection",
JSON.stringify({ theme: "claw", mode: resolvedTheme }),
);
window.localStorage.setItem("clawhub-theme", resolvedTheme);
window.localStorage.setItem("clawhub-theme-name", "claw");
document.cookie = `clawhub-theme=${resolvedTheme}; path=/`;
}, theme);
}
async function main() {
const baseUrl = argument("--base-url");
const output = argument("--output");
const screenshotDir = argument("--screenshots");
if (!baseUrl || !output || !screenshotDir) {
throw new Error(
"usage: browser-check.ts --base-url <url> --output <path> --screenshots <directory>",
);
}
await mkdir(screenshotDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const evidence: BrowserEvidence[] = [];
try {
for (const [viewportName, viewport] of Object.entries(viewports)) {
for (const theme of ["dark", "light"] as const) {
const context = await browser.newContext({
colorScheme: theme,
viewport,
});
await setTheme(context, theme);
const page = await context.newPage();
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
for (const route of routes) {
await page.goto(new URL(route, baseUrl).toString(), {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
await page.waitForTimeout(1_500);
const actualTheme = await page.locator("html").getAttribute("data-theme-resolved");
if (actualTheme !== theme) {
throw new Error(`${route} resolved ${actualTheme ?? "no theme"} instead of ${theme}`);
}
const routeName = route === "/" ? "home" : route.slice(1).replaceAll("/", "-");
const screenshot = join(screenshotDir, `${routeName}-${theme}-${viewportName}.png`);
await page.screenshot({ path: screenshot, fullPage: true });
const pageEvidence = await page.evaluate(() => {
const interactive = [
...document.querySelectorAll<HTMLElement>(
"button, a[href], input, select, textarea, [role=button], [role=link]",
),
];
const unnamedInteractiveElements = interactive
.filter((element) => {
const text = element.textContent?.trim();
const label = element.getAttribute("aria-label")?.trim();
const labelledBy = element.getAttribute("aria-labelledby")?.trim();
const title = element.getAttribute("title")?.trim();
const alt = element.querySelector("img")?.getAttribute("alt")?.trim();
return !text && !label && !labelledBy && !title && !alt;
})
.slice(0, 20)
.map((element) => {
const id = element.id ? `#${element.id}` : "";
const classes =
typeof element.className === "string" && element.className.trim()
? `.${element.className.trim().split(/\s+/).slice(0, 3).join(".")}`
: "";
return `${element.tagName.toLowerCase()}${id}${classes}`;
});
return {
horizontalOverflow: Math.max(
0,
document.documentElement.scrollWidth - window.innerWidth,
),
unnamedInteractiveElements,
};
});
evidence.push({
route,
theme,
viewport: viewportName as keyof typeof viewports,
screenshot,
horizontalOverflow: pageEvidence.horizontalOverflow,
unnamedInteractiveElements: pageEvidence.unnamedInteractiveElements,
pageErrors: [...pageErrors],
});
pageErrors.length = 0;
}
await context.close();
}
}
} finally {
await browser.close();
}
await writeFile(
output,
`${JSON.stringify(
{
baseUrl,
routes,
evidence,
},
null,
2,
)}\n`,
);
console.log(`captured ${evidence.length} route/theme/viewport combinations`);
}
if (import.meta.main) {
await main();
}
@@ -0,0 +1,43 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["findings", "changedFiles", "deferredSuggestions"],
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"severity",
"kind",
"file",
"line",
"message",
"remediation",
"reference"
],
"properties": {
"id": { "type": "string", "minLength": 1 },
"severity": { "enum": ["error", "warning", "info"] },
"kind": { "enum": ["mechanical", "judgment"] },
"file": { "type": "string", "minLength": 1 },
"line": { "type": "integer", "minimum": 1 },
"message": { "type": "string", "minLength": 1 },
"remediation": { "type": "string", "minLength": 1 },
"reference": { "type": "string", "minLength": 1 }
}
}
},
"changedFiles": {
"type": "array",
"items": { "type": "string" }
},
"deferredSuggestions": {
"type": "array",
"items": { "type": "string" }
}
}
}
@@ -0,0 +1,105 @@
/* @vitest-environment node */
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { parse as parseYaml } from "yaml";
type Step = {
name?: string;
if?: string;
run?: string;
uses?: string;
with?: Record<string, unknown>;
};
describe("weekly design-system audit workflow", () => {
it("runs Monday and manually, with one guarded draft PR branch", async () => {
const source = await readFile(".github/workflows/design-system-audit.yml", "utf8");
const workflow = parseYaml(source) as {
on: {
schedule: Array<{ cron: string }>;
workflow_dispatch: unknown;
};
env: Record<string, string>;
jobs: { audit: { steps: Step[] } };
};
const steps = workflow.jobs.audit.steps;
expect(workflow.on.schedule).toEqual([{ cron: "17 15 * * 1" }]);
expect(workflow.on.workflow_dispatch).toBeDefined();
expect(workflow.env.AUDIT_BRANCH).toBe("automation/design-system-audit");
expect(workflow.env.ARTIFACT_DIRECTORY).toBe("artifacts/design-audit");
expect(source.toLowerCase()).not.toContain("linear");
expect(source).not.toContain("gh pr merge");
const createPr = steps.find((step) => step.name === "Open or update draft pull request");
expect(createPr?.run).toContain("--draft");
expect(createPr?.run).toContain("gh pr edit");
expect(createPr?.run).toContain('--head "$AUDIT_BRANCH"');
const closeClean = steps.find(
(step) => step.name === "Close obsolete clean audit pull request",
);
expect(closeClean?.if).toContain("steps.validation.outcome == 'success'");
expect(closeClean?.run).toContain("gh pr close");
expect(steps.some((step) => step.name === "Create repository token")).toBe(false);
expect(source).not.toContain("DESIGN_SYSTEM_READ_TOKEN");
expect(source).not.toContain("steps.app-token.outputs.token");
});
it("preserves artifacts and suppresses PRs when validation fails", async () => {
const workflow = parseYaml(
await readFile(".github/workflows/design-system-audit.yml", "utf8"),
) as { jobs: { audit: { steps: Step[] } } };
const steps = workflow.jobs.audit.steps;
const upload = steps.find((step) => step.name === "Upload audit artifacts");
const commit = steps.find((step) => step.name === "Commit audit branch");
const failure = steps.find((step) => step.name === "Fail unsuccessful audit");
expect(upload?.if).toBe("always()");
expect(upload?.uses).toMatch(/^actions\/upload-artifact@[0-9a-f]{40}$/);
expect(commit?.if).toBe("steps.finalize.outputs.open_pr == 'true'");
expect(failure?.if).toContain("steps.validation.outcome == 'failure'");
});
it("enforces the agent change boundary before running repository scripts", async () => {
const workflow = parseYaml(
await readFile(".github/workflows/design-system-audit.yml", "utf8"),
) as { jobs: { audit: { steps: Step[] } } };
const steps = workflow.jobs.audit.steps;
const boundaryIndex = steps.findIndex((step) => step.name === "Validate agent change boundary");
const deterministicIndex = steps.findIndex(
(step) => step.name === "Re-run deterministic checks on agent patch",
);
const validationIndex = steps.findIndex(
(step) => step.name === "Validate proposed source fixes",
);
expect(boundaryIndex).toBeGreaterThan(-1);
expect(boundaryIndex).toBeLessThan(deterministicIndex);
expect(deterministicIndex).toBeLessThan(validationIndex);
expect(steps[boundaryIndex]?.run).toContain("validate-changes.ts");
expect(steps[deterministicIndex]?.run).toContain("--working-tree");
expect(steps[deterministicIndex]?.run).toContain("--fail-on-findings");
expect(steps[validationIndex]?.if).toContain("steps.change_boundary.outcome == 'success'");
expect(steps[validationIndex]?.if).toContain("steps.post_source.outcome == 'success'");
});
it("pins the design release and audit inputs in every report", async () => {
const source = await readFile(".github/workflows/design-system-audit.yml", "utf8");
expect(source).toContain(
"require('./node_modules/@openclaw/design-system/package.json').version",
);
expect(source).toContain("repos/openclaw/design-system/releases/tags/${release}");
expect(source).toContain("https://github.com/openclaw/design-system.git");
expect(source).toContain('clone \\\n --branch "$release"');
expect(source).toContain("--consumer-sha");
expect(source).toContain("--base-sha");
expect(source).toContain("--release");
expect(source).toContain("browser-check.ts");
expect(source).toContain("run-codex.ts");
expect(source).toContain(
"set -euo pipefail\n {\n bun run test:ui-contract",
);
});
});
+115
View File
@@ -0,0 +1,115 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import { validateSafeChanges } from "./finalize";
import {
curatedFindings,
renderAuditMarkdown,
shouldOpenAuditPullRequest,
sortAndDedupeFindings,
summarizeFindings,
type AuditFinding,
type AuditReport,
} from "./report";
import { changedLineFindings, parseAddedLines } from "./source-check";
const finding = (severity: AuditFinding["severity"], id = "token/raw-color"): AuditFinding => ({
id,
severity,
kind: "mechanical",
file: "src/example.css",
line: 12,
message: "Example finding.",
remediation: "Use a semantic token.",
reference: "openclaw-design-system/references/tokens.md",
});
describe("design audit reporting", () => {
it("uses the released skill significance threshold", () => {
expect(shouldOpenAuditPullRequest({ errors: 1, warnings: 0, info: 0 })).toBe(true);
expect(shouldOpenAuditPullRequest({ errors: 0, warnings: 1, info: 0 })).toBe(true);
expect(shouldOpenAuditPullRequest({ errors: 0, warnings: 0, info: 5 })).toBe(false);
expect(shouldOpenAuditPullRequest({ errors: 0, warnings: 0, info: 6 })).toBe(true);
});
it("includes every error and at most five non-errors in the concise report", () => {
const findings = [
finding("error", "a"),
finding("error", "b"),
...Array.from({ length: 8 }, (_, index) => finding("info", `info-${index}`)),
];
const curated = curatedFindings(findings);
expect(curated.expanded.filter((item) => item.severity === "error")).toHaveLength(2);
expect(curated.expanded.filter((item) => item.severity !== "error")).toHaveLength(5);
expect(curated.omittedNonErrors).toBe(3);
});
it("deduplicates and sorts findings by severity", () => {
const warning = finding("warning", "warning");
const findings = sortAndDedupeFindings([
finding("info", "info"),
warning,
warning,
finding("error", "error"),
]);
expect(findings.map((item) => item.severity)).toEqual(["error", "warning", "info"]);
expect(summarizeFindings(findings)).toEqual({ errors: 1, warnings: 1, info: 1 });
});
it("renders immutable audit provenance", () => {
const report: AuditReport = {
designSystemVersion: "v0.0.3",
consumerSha: "consumer",
auditBaseSha: "base",
generatedAt: "2026-07-08T00:00:00.000Z",
summary: { errors: 0, warnings: 0, info: 0 },
findings: [],
changedFiles: [],
validationCommands: ["bun run ci:static"],
renderedRoutes: ["/"],
screenshotPaths: ["home.png"],
validationPassed: true,
};
const markdown = renderAuditMarkdown(report);
expect(markdown).toContain("`v0.0.3`");
expect(markdown).toContain("`consumer`");
expect(markdown).toContain("No significant design-system drift was found.");
});
});
describe("deterministic source checks", () => {
it("tracks exact added source line numbers", () => {
const added = parseAddedLines(
[
"diff --git a/src/example.css b/src/example.css",
"--- a/src/example.css",
"+++ b/src/example.css",
"@@ -10,0 +11,2 @@",
"+color: #f5654a;",
"+background: var(--accent);",
].join("\n"),
);
expect(added).toEqual([
{ file: "src/example.css", line: 11, text: "color: #f5654a;" },
{ file: "src/example.css", line: 12, text: "background: var(--accent);" },
]);
expect(changedLineFindings(added).map((item) => item.id)).toEqual([
"token/raw-color",
"token/legacy-alias",
]);
});
});
describe("safe fix boundary", () => {
it("allows bounded existing frontend changes", () => {
expect(() => validateSafeChanges(["src/example.css"], "10\t5\tsrc/example.css")).not.toThrow();
});
it("rejects backend, configuration, and broad changes", () => {
expect(() => validateSafeChanges(["convex/schema.ts"], "1\t0\tconvex/schema.ts")).toThrow(
"outside existing frontend source",
);
expect(() => validateSafeChanges(["src/example.css"], "401\t0\tsrc/example.css")).toThrow(
"maximum is 400",
);
});
});
+152
View File
@@ -0,0 +1,152 @@
import { execFileSync } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
parseFindings,
renderAuditMarkdown,
renderPullRequestBody,
shouldOpenAuditPullRequest,
sortAndDedupeFindings,
summarizeFindings,
type AuditReport,
} from "./report";
const validationCommands = [
"bun run test:ui-contract",
"bun run ci:static",
"bun run ci:unit",
"bun run ci:types-build",
"bun run ci:playwright-smoke",
];
function argument(name: string) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function git(...args: string[]) {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}
export function validateSafeChanges(paths: string[], numstat: string) {
if (paths.length > 12) throw new Error(`audit changed ${paths.length} files; maximum is 12`);
for (const path of paths) {
if (!/^src\/.+\.(css|ts|tsx)$/.test(path)) {
throw new Error(`audit attempted a change outside existing frontend source: ${path}`);
}
}
const changedLines = numstat
.split("\n")
.filter(Boolean)
.reduce((total, line) => {
const [added, removed] = line.split("\t");
return total + Number(added || 0) + Number(removed || 0);
}, 0);
if (changedLines > 400) {
throw new Error(`audit changed ${changedLines} lines; maximum is 400`);
}
}
export function validateCurrentSafeChanges() {
const changedFiles = git("diff", "--name-only", "HEAD").split("\n").filter(Boolean);
const untracked = git("ls-files", "--others", "--exclude-standard").split("\n").filter(Boolean);
if (untracked.length > 0) {
throw new Error(`audit created untracked files: ${untracked.join(", ")}`);
}
validateSafeChanges(changedFiles, git("diff", "--numstat", "HEAD"));
return changedFiles;
}
async function main() {
const deterministicPath = argument("--deterministic");
const codexPath = argument("--codex");
const browserPath = argument("--browser");
const outputDirectory = argument("--output-directory");
const release = argument("--release");
const baseSha = argument("--base-sha");
const consumerSha = argument("--consumer-sha");
const validationPassed = argument("--validation-passed") === "true";
const runUrl = argument("--run-url") ?? "local";
if (
!deterministicPath ||
!codexPath ||
!browserPath ||
!outputDirectory ||
!release ||
!baseSha ||
!consumerSha
) {
throw new Error("missing required design-audit finalize argument");
}
const changedFiles = validateCurrentSafeChanges();
const deterministic = JSON.parse(await readFile(deterministicPath, "utf8"));
const codex = JSON.parse(await readFile(codexPath, "utf8"));
const browser = JSON.parse(await readFile(browserPath, "utf8")) as {
routes: string[];
evidence: Array<{ screenshot: string }>;
};
const findings = sortAndDedupeFindings([
...parseFindings(deterministic),
...parseFindings(codex),
]);
const report: AuditReport = {
designSystemVersion: release,
consumerSha,
auditBaseSha: baseSha,
generatedAt: new Date().toISOString(),
summary: summarizeFindings(findings),
findings,
changedFiles,
validationCommands,
renderedRoutes: browser.routes,
screenshotPaths: browser.evidence.map((entry) => entry.screenshot),
validationPassed,
};
await mkdir(outputDirectory, { recursive: true });
await writeFile(
join(outputDirectory, "design-audit.json"),
`${JSON.stringify(report, null, 2)}\n`,
);
await writeFile(join(outputDirectory, "design-audit.md"), renderAuditMarkdown(report));
await writeFile(
join(outputDirectory, "pull-request-body.md"),
renderPullRequestBody(report, runUrl),
);
const openPullRequest = validationPassed && shouldOpenAuditPullRequest(report.summary);
if (openPullRequest) {
const committedDirectory = "design-audits/latest";
await mkdir(committedDirectory, { recursive: true });
const committedJson = join(committedDirectory, "design-audit.json");
await writeFile(committedJson, `${JSON.stringify(report, null, 2)}\n`);
await writeFile(join(committedDirectory, "design-audit.md"), renderAuditMarkdown(report));
execFileSync("bunx", ["oxfmt", "--write", committedJson], { stdio: "inherit" });
}
const githubOutput = process.env.GITHUB_OUTPUT;
if (githubOutput) {
await mkdir(dirname(githubOutput), { recursive: true });
await writeFile(
githubOutput,
[
`open_pr=${openPullRequest}`,
`has_fixes=${changedFiles.length > 0}`,
`errors=${report.summary.errors}`,
`warnings=${report.summary.warnings}`,
`info=${report.summary.info}`,
"",
].join("\n"),
{ flag: "a" },
);
}
console.log(
`finalized audit: ${report.summary.errors} errors, ${report.summary.warnings} warnings, ${report.summary.info} info; PR=${openPullRequest}`,
);
}
if (import.meta.main) {
await main();
}
+224
View File
@@ -0,0 +1,224 @@
import { readFile, writeFile } from "node:fs/promises";
export type AuditSeverity = "error" | "warning" | "info";
export type AuditKind = "mechanical" | "judgment";
export type AuditFinding = {
id: string;
severity: AuditSeverity;
kind: AuditKind;
file: string;
line: number;
message: string;
remediation: string;
reference: string;
};
export type AuditSummary = {
errors: number;
warnings: number;
info: number;
};
export type AuditReport = {
designSystemVersion: string;
consumerSha: string;
auditBaseSha: string;
generatedAt: string;
summary: AuditSummary;
findings: AuditFinding[];
changedFiles: string[];
validationCommands: string[];
renderedRoutes: string[];
screenshotPaths: string[];
validationPassed: boolean;
};
const severityRank: Record<AuditSeverity, number> = {
error: 0,
warning: 1,
info: 2,
};
function findingKey(finding: AuditFinding) {
return [finding.id, finding.file, finding.line, finding.message].join("\u0000");
}
export function isAuditFinding(value: unknown): value is AuditFinding {
if (!value || typeof value !== "object") return false;
const finding = value as Partial<AuditFinding>;
return (
typeof finding.id === "string" &&
["error", "warning", "info"].includes(finding.severity ?? "") &&
["mechanical", "judgment"].includes(finding.kind ?? "") &&
typeof finding.file === "string" &&
Number.isInteger(finding.line) &&
Number(finding.line) > 0 &&
typeof finding.message === "string" &&
typeof finding.remediation === "string" &&
typeof finding.reference === "string"
);
}
export function parseFindings(value: unknown): AuditFinding[] {
if (!value || typeof value !== "object") throw new Error("audit result must be an object");
const findings = (value as { findings?: unknown }).findings;
if (!Array.isArray(findings) || !findings.every(isAuditFinding)) {
throw new Error("audit result findings do not match the report contract");
}
return findings;
}
export function sortAndDedupeFindings(findings: AuditFinding[]) {
const unique = new Map<string, AuditFinding>();
for (const finding of findings) {
const key = findingKey(finding);
const existing = unique.get(key);
if (!existing || severityRank[finding.severity] < severityRank[existing.severity]) {
unique.set(key, finding);
}
}
return [...unique.values()].sort(
(left, right) =>
severityRank[left.severity] - severityRank[right.severity] ||
left.id.localeCompare(right.id) ||
left.file.localeCompare(right.file) ||
left.line - right.line,
);
}
export function summarizeFindings(findings: AuditFinding[]): AuditSummary {
return findings.reduce<AuditSummary>(
(summary, finding) => {
if (finding.severity === "error") summary.errors += 1;
else if (finding.severity === "warning") summary.warnings += 1;
else summary.info += 1;
return summary;
},
{ errors: 0, warnings: 0, info: 0 },
);
}
export function shouldOpenAuditPullRequest(summary: AuditSummary) {
return summary.errors > 0 || summary.warnings > 0 || summary.info > 5;
}
export function curatedFindings(findings: AuditFinding[]) {
const sorted = sortAndDedupeFindings(findings);
const errors = sorted.filter((finding) => finding.severity === "error");
const nonErrors = sorted.filter((finding) => finding.severity !== "error");
return {
expanded: [...errors, ...nonErrors.slice(0, 5)],
omittedNonErrors: Math.max(0, nonErrors.length - 5),
};
}
function findingMarkdown(finding: AuditFinding) {
return [
`### ${finding.severity.toUpperCase()}: \`${finding.id}\``,
"",
`- Evidence: [${finding.file}](../../${finding.file}#L${finding.line})`,
`- Kind: ${finding.kind}`,
`- Finding: ${finding.message}`,
`- Remediation: ${finding.remediation}`,
`- Contract: \`${finding.reference}\``,
].join("\n");
}
export function renderAuditMarkdown(report: AuditReport) {
const { expanded, omittedNonErrors } = curatedFindings(report.findings);
const lines = [
"# ClawHub design audit",
"",
`- Design system: \`${report.designSystemVersion}\``,
`- ClawHub commit: \`${report.consumerSha}\``,
`- Comparison base: \`${report.auditBaseSha}\``,
`- Generated: ${report.generatedAt}`,
`- Validation: ${report.validationPassed ? "passed" : "failed"}`,
"",
"## Summary",
"",
`- Errors: ${report.summary.errors}`,
`- Warnings: ${report.summary.warnings}`,
`- Informational: ${report.summary.info}`,
`- Safe source fixes: ${report.changedFiles.length}`,
"",
"## Validation",
"",
...report.validationCommands.map((command) => `- \`${command}\``),
"",
"## Rendered routes",
"",
...report.renderedRoutes.map((route) => `- \`${route}\``),
"",
"## Findings",
"",
];
if (expanded.length === 0) {
lines.push("No significant design-system drift was found.");
} else {
lines.push(...expanded.flatMap((finding) => [findingMarkdown(finding), ""]));
}
if (omittedNonErrors > 0) {
lines.push(`${omittedNonErrors} additional non-error findings are retained in JSON.`);
}
return `${lines.join("\n").trim()}\n`;
}
export function renderPullRequestBody(report: AuditReport, runUrl: string) {
const { expanded, omittedNonErrors } = curatedFindings(report.findings);
const lines = [
"## Audit",
"",
`- Design system: \`${report.designSystemVersion}\``,
`- Audited ClawHub SHA: \`${report.consumerSha}\``,
`- Comparison base: \`${report.auditBaseSha}\``,
`- Findings: ${report.summary.errors} errors, ${report.summary.warnings} warnings, ${report.summary.info} informational`,
`- Safe fixes included: ${report.changedFiles.length > 0 ? report.changedFiles.join(", ") : "none"}`,
`- Workflow run: ${runUrl}`,
"",
"## Validation",
"",
...report.validationCommands.map((command) => `- \`${command}\``),
"",
"## Curated findings",
"",
];
if (expanded.length === 0) {
lines.push("No significant design-system drift was found.");
} else {
lines.push(
...expanded.map(
(finding) =>
`- **${finding.severity}** \`${finding.id}\` at \`${finding.file}:${finding.line}\`: ${finding.message}`,
),
);
}
if (omittedNonErrors > 0) {
lines.push(`- ${omittedNonErrors} additional non-error findings are in the JSON report.`);
}
lines.push(
"",
"## Artifacts",
"",
"- `design-audits/latest/design-audit.json`",
"- `design-audits/latest/design-audit.md`",
"- screenshots and full logs are attached to the workflow run",
"",
"This pull request is intentionally draft. The workflow never merges, deploys, publishes, or mutates backend data.",
);
return `${lines.join("\n")}\n`;
}
export async function readFindings(path: string) {
return parseFindings(JSON.parse(await readFile(path, "utf8")));
}
export async function writeReport(path: string, report: AuditReport) {
await writeFile(path, `${JSON.stringify(report, null, 2)}\n`);
}
+117
View File
@@ -0,0 +1,117 @@
import { spawn } from "node:child_process";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { parseFindings } from "./report";
function argument(name: string) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function run(command: string, args: string[], input: string, logPath: string) {
return new Promise<void>((resolvePromise, reject) => {
const child = spawn(command, args, {
cwd: process.cwd(),
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
let stderr = "";
const chunks: Buffer[] = [];
child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
process.stderr.write(chunk);
});
child.on("error", reject);
child.on("close", async (code) => {
await writeFile(logPath, Buffer.concat(chunks));
if (code === 0) resolvePromise();
else reject(new Error(`${command} exited ${code}: ${stderr.slice(-2_000)}`));
});
child.stdin.end(input);
});
}
async function main() {
const deterministicPath = argument("--deterministic");
const browserPath = argument("--browser");
const outputPath = argument("--output");
const eventsPath = argument("--events");
const screenshotDir = argument("--screenshots");
const designSystemDirectory = argument("--design-system");
if (
!deterministicPath ||
!browserPath ||
!outputPath ||
!eventsPath ||
!screenshotDir ||
!designSystemDirectory
) {
throw new Error(
"usage: run-codex.ts --deterministic <path> --browser <path> --screenshots <dir> --design-system <dir> --output <path> --events <path>",
);
}
await mkdir(dirname(outputPath), { recursive: true });
const screenshots = (await readdir(screenshotDir))
.filter((path) => path.endsWith(".png"))
.sort()
.map((path) => resolve(screenshotDir, path));
const deterministic = await readFile(deterministicPath, "utf8");
const browser = await readFile(browserPath, "utf8");
const prompt = `You are running the scheduled ClawHub design audit.
Read and follow:
- ${designSystemDirectory}/openclaw-design-audit/SKILL.md and every referenced file
- ${designSystemDirectory}/openclaw-design-system/references/tokens.md
- ${designSystemDirectory}/openclaw-design-system/references/consumer-adapters.md
Audit the current repository. Deterministic checks ran first and produced:
${deterministic}
Real-browser checks and screenshot paths:
${browser}
Inspect the attached screenshots for desktop/mobile and light/dark parity. Verify every finding against source. Never invent a source file or line. Cover semantic tokens, shared primitive usage, deprecated aliases, accessibility, copy clarity, responsive behavior, and recurring pattern candidates.
You may apply only narrow, high-confidence fixes allowed by fix-policy.md. Edit only existing frontend files under src/ with .ts, .tsx, or .css extensions. Do not edit backend behavior, dependencies, workflows, configuration, generated files, reports, or design-audit scripts. Do not add files. Do not make broad redesign, navigation, hierarchy, or information-architecture changes. Keep subjective ideas as informational findings without source edits.
Return every confirmed error. Return warnings before informational suggestions. Include precise repository-relative source locations and canonical design-system references. Report the exact files you changed.`;
const args = [
"exec",
"--cd",
process.cwd(),
"--model",
process.env.DESIGN_AUDIT_CODEX_MODEL ?? "gpt-5.5",
"--sandbox",
"workspace-write",
"--ignore-user-config",
"-c",
"approval_policy=never",
"-c",
`model_reasoning_effort=${process.env.DESIGN_AUDIT_REASONING_EFFORT ?? "high"}`,
"-c",
`service_tier=${process.env.DESIGN_AUDIT_SERVICE_TIER ?? "fast"}`,
"-c",
'shell_environment_policy.inherit="core"',
"-c",
"shell_environment_policy.ignore_default_excludes=false",
"--output-schema",
join(process.cwd(), "scripts/design-audit/codex-output.schema.json"),
"--output-last-message",
outputPath,
"--ephemeral",
"--json",
];
for (const screenshot of screenshots) args.push("--image", screenshot);
args.push("-");
await run("codex", args, prompt, eventsPath);
const parsed = JSON.parse(await readFile(outputPath, "utf8"));
parseFindings(parsed);
console.log(`Codex returned ${parsed.findings.length} findings`);
}
if (import.meta.main) {
await main();
}
+212
View File
@@ -0,0 +1,212 @@
import { execFileSync } from "node:child_process";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { dirname, extname, join, relative } from "node:path";
import type { AuditFinding } from "./report";
type AddedLine = {
file: string;
line: number;
text: string;
};
const rawPaletteTokens: Record<string, string> = {
"#101012": "--oc-bg-page",
"#19191c": "--oc-bg-surface",
"#202024": "--oc-bg-elevated",
"#ededed": "--oc-text-primary",
"#bcbcc4": "--oc-text-secondary",
"#9a9aa2": "--oc-text-muted",
"#f5654a": "--oc-accent-primary",
"#d84a31": "--oc-accent-primary",
"#4fc8ae": "--oc-accent-secondary",
"#14806e": "--oc-accent-secondary",
};
const legacyAliases = new Set([
"--bg",
"--bg-soft",
"--surface",
"--surface-muted",
"--ink",
"--ink-soft",
"--ink-muted",
"--accent",
"--accent-fg",
"--accent-deep",
"--seafoam",
"--line",
"--border-ui",
"--input-bg",
"--input-border",
]);
function git(...args: string[]) {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}
export function parseAddedLines(diff: string): AddedLine[] {
const lines = diff.split("\n");
const added: AddedLine[] = [];
let file = "";
let nextLine = 0;
for (const line of lines) {
if (line.startsWith("+++ b/")) {
file = line.slice(6);
continue;
}
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
if (hunk) {
nextLine = Number(hunk[1]);
continue;
}
if (!file || line.startsWith("---")) continue;
if (line.startsWith("+")) {
added.push({ file, line: nextLine, text: line.slice(1) });
nextLine += 1;
} else if (!line.startsWith("-")) {
nextLine += 1;
}
}
return added;
}
async function listSourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(
entries.map(async (entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return listSourceFiles(path);
return [".css", ".ts", ".tsx"].includes(extname(entry.name)) ? [path] : [];
}),
);
return nested.flat();
}
function lineForOffset(source: string, offset: number) {
return source.slice(0, offset).split("\n").length;
}
async function undefinedTokenFindings(designSystemStyles: string): Promise<AuditFinding[]> {
const sourceFiles = await listSourceFiles("src");
const packageFiles = await listSourceFiles(designSystemStyles);
const definitions = new Set<string>();
const references: Array<{ file: string; line: number; token: string }> = [];
for (const path of [...sourceFiles, ...packageFiles]) {
const source = await readFile(path, "utf8");
for (const match of source.matchAll(/(--oc-[\w-]+)\s*:/g)) {
definitions.add(match[1]!);
}
if (!path.startsWith("src/")) continue;
for (const match of source.matchAll(/var\((--oc-[\w-]+)/g)) {
references.push({
file: relative(".", path),
line: lineForOffset(source, match.index),
token: match[1]!,
});
}
}
const firstByToken = new Map<string, (typeof references)[number]>();
for (const reference of references) {
if (!definitions.has(reference.token) && !firstByToken.has(reference.token)) {
firstByToken.set(reference.token, reference);
}
}
return [...firstByToken.values()].map((reference) => ({
id: "token/undefined",
severity: "error",
kind: "mechanical",
file: reference.file,
line: reference.line,
message: `${reference.token} is referenced but is not defined by ClawHub or the installed design system.`,
remediation:
"Define the semantic token in the owning layer or replace it with an existing token.",
reference: "openclaw-design-system/references/tokens.md",
}));
}
export function changedLineFindings(addedLines: AddedLine[]): AuditFinding[] {
const findings: AuditFinding[] = [];
for (const added of addedLines) {
if (!added.file.startsWith("src/")) continue;
const normalized = added.text.toLowerCase();
for (const [raw, token] of Object.entries(rawPaletteTokens)) {
if (!normalized.includes(raw)) continue;
findings.push({
id: "token/raw-color",
severity: "warning",
kind: "mechanical",
file: added.file,
line: added.line,
message: `A newly added raw OpenClaw palette value (${raw}) bypasses semantic theme intent.`,
remediation: `Replace it with var(${token}) when that token matches the UI role.`,
reference: "openclaw-design-system/references/tokens.md",
});
}
for (const match of added.text.matchAll(/var\((--[\w-]+)/g)) {
if (!legacyAliases.has(match[1]!)) continue;
findings.push({
id: "token/legacy-alias",
severity: "warning",
kind: "mechanical",
file: added.file,
line: added.line,
message: `New code depends on migration-only alias ${match[1]}.`,
remediation: "Use the equivalent canonical --oc-* semantic token.",
reference: "openclaw-design-system/references/consumer-adapters.md",
});
}
}
return findings;
}
async function main() {
const outputIndex = process.argv.indexOf("--output");
const baseIndex = process.argv.indexOf("--base");
const stylesIndex = process.argv.indexOf("--design-system-styles");
const workingTree = process.argv.includes("--working-tree");
const failOnFindings = process.argv.includes("--fail-on-findings");
const output = outputIndex >= 0 ? process.argv[outputIndex + 1] : undefined;
const base = baseIndex >= 0 ? process.argv[baseIndex + 1] : undefined;
const designSystemStyles =
stylesIndex >= 0
? process.argv[stylesIndex + 1]
: "node_modules/@openclaw/design-system/styles";
if (!output || (!base && !workingTree) || !designSystemStyles) {
throw new Error(
"usage: source-check.ts (--base <sha> | --working-tree) --output <path> [--design-system-styles <dir>] [--fail-on-findings]",
);
}
const diff = workingTree
? git("diff", "--unified=0", "--no-ext-diff", "HEAD", "--", "src")
: git("diff", "--unified=0", "--no-ext-diff", `${base}..HEAD`, "--", "src");
const findings = [
...(await undefinedTokenFindings(designSystemStyles)),
...changedLineFindings(parseAddedLines(diff)),
];
await mkdir(dirname(output), { recursive: true });
await writeFile(
output,
`${JSON.stringify(
{
baseSha: workingTree ? git("rev-parse", "HEAD") : base,
consumerSha: git("rev-parse", "HEAD"),
findings,
},
null,
2,
)}\n`,
);
console.log(`wrote ${findings.length} deterministic findings to ${output}`);
if (failOnFindings && findings.length > 0) {
throw new Error(`post-agent deterministic checks found ${findings.length} violation(s)`);
}
}
if (import.meta.main) {
await main();
}
+8
View File
@@ -0,0 +1,8 @@
import { validateCurrentSafeChanges } from "./finalize";
const changedFiles = validateCurrentSafeChanges();
console.log(
changedFiles.length === 0
? "audit proposed no source changes"
: `audit proposed ${changedFiles.length} safe frontend change(s): ${changedFiles.join(", ")}`,
);
+35
View File
@@ -25,6 +25,41 @@
"skillPath": "skills/controlling-costs/SKILL.md",
"computedHash": "73ed9a6417283ee4029a9e61d11ee3cc9ae64268d131150c4c0f4a71f87cd549"
},
"openclaw-brand": {
"source": "openclaw/design-system",
"ref": "v0.0.1",
"sourceType": "github",
"skillPath": "openclaw-brand/SKILL.md",
"computedHash": "d4bf50bb9d512dbfbcd4abde7a75d8399b0187ca9486dd7854844aab0cd02e6a"
},
"openclaw-design": {
"source": "openclaw/design-system",
"ref": "v0.0.1",
"sourceType": "github",
"skillPath": "SKILL.md",
"computedHash": "8de963d26ec19a920da5e2a5d029764bba7c941d5cb051c67a877d0e5d91c6a4"
},
"openclaw-design-audit": {
"source": "openclaw/design-system",
"ref": "v0.0.1",
"sourceType": "github",
"skillPath": "openclaw-design-audit/SKILL.md",
"computedHash": "12fec90fd4aa1aa569de1beb5a3c85d06887a5e548704dbd094355edc0f52ad3"
},
"openclaw-design-system": {
"source": "openclaw/design-system",
"ref": "v0.0.1",
"sourceType": "github",
"skillPath": "openclaw-design-system/SKILL.md",
"computedHash": "475604f08f37186237fa62605fbfe16ea04367af81f4c3a8c59bcb395ae70cca"
},
"openclaw-marketing-pages": {
"source": "openclaw/design-system",
"ref": "v0.0.1",
"sourceType": "github",
"skillPath": "openclaw-marketing-pages/SKILL.md",
"computedHash": "7da44d258d5a1e87b26535effa13cf20931fd04a884fd95225a4ff603932ef84"
},
"query-metrics": {
"source": "axiomhq/skills",
"sourceType": "github",
+122 -6
View File
@@ -68,8 +68,109 @@ describe("restored UI design contract", () => {
const publicRegistry = () => read("src/lib/publicRegistry.ts");
const settings = () => read("src/routes/settings.tsx");
const styles = () => read("src/styles.css");
const designSystemStyles = () => read("src/design-system.css");
const theme = () => read("src/lib/theme.ts");
it("loads the shared OpenClaw token adapter after the legacy application stylesheet", () => {
const rootSource = rootRoute();
const sharedCss = designSystemStyles();
expect(sharedCss).toContain('@import "@openclaw/design-system/tokens.css";');
expect(sharedCss).toContain('@import "@openclaw/design-system/typography.css";');
expect(sharedCss).toContain('@import "@openclaw/design-system/themes/product.css";');
expect(sharedCss).toContain('@import "@openclaw/design-system/components.css";');
expect(sharedCss).toContain('@import "@openclaw/design-system/compat/clawhub.css";');
expect(sharedCss).toContain(".home-v2-main.oc-app-surface");
expect(sharedCss).toContain("--hv2-bg: var(--oc-bg-page)");
expect(sharedCss).toContain("--hv2-text: var(--oc-text-primary)");
expect(sharedCss).toContain("--hv2-accent: var(--oc-accent-primary)");
expect(sharedCss).toContain("--hv2-radius-md: var(--oc-radius-surface)");
expect(sharedCss).toContain("border-radius: var(--oc-radius-surface)");
expect(sharedCss).toContain("border-radius: var(--oc-radius-control)");
expect(sharedCss).toContain("border-radius: var(--oc-radius-inset)");
expect(sharedCss).toContain("@media (max-width: 760px)");
expect(sharedCss).toContain(
".home-v2-main .home-v2-popular-publishers-header.oc-section-header",
);
expect(sharedCss).toContain('[data-theme-family="claw"][data-theme-resolved="light"]');
expect(sharedCss).toContain("--accent: var(--oc-accent-primary)");
expect(sharedCss).toContain('[data-theme-family="claw"][data-theme-mode="system"]');
expect(rootSource.indexOf("href: designSystemCss")).toBeGreaterThan(
rootSource.indexOf("href: appCss"),
);
});
it("uses semantic design-system geometry across landing controls and surfaces", () => {
const css = styles();
for (const selector of [
".home-v2-headline-trigger",
".home-v2-listing-search-bar",
".home-v2-listing-search-close",
".home-v2-listing-category-trigger",
]) {
expect(cssRule(css, selector)).toContain("border-radius: var(--oc-radius-control)");
}
expect(cssRule(css, ".home-v2-promotion-title-icon")).toContain(
"border-radius: var(--oc-radius-inset)",
);
expect(cssRule(css, ".home-v2-apps-workflow-tile")).toContain(
"border-radius: var(--oc-radius-surface)",
);
expect(cssRule(css, ".home-v2-apps-tile-icon")).toContain(
"border-radius: var(--oc-radius-inset)",
);
for (const selector of [
"\n.marketplace-icon",
".browse-page .browse-results-grid .skill-card-header .marketplace-icon",
".home-v2-listing-card-icon .marketplace-icon",
".home-v2-listing-row-icon .marketplace-icon",
".home-v2-popular-publisher-card .marketplace-icon",
".navbar-search-typeahead-icon .marketplace-icon",
".browse-page .dashboard-catalog-row-icon .marketplace-icon",
]) {
expect(cssRule(css, selector)).toContain("border-radius: var(--oc-radius-inset)");
}
expect(cssRule(css, ".marketplace-icon-user")).toContain(
"border-radius: var(--oc-radius-round)",
);
});
it("keeps migrated application surfaces on canonical semantic tokens", () => {
const css = styles();
const sharedCss = designSystemStyles();
for (const legacyReference of [
"var(--danger)",
"var(--font-sans)",
"var(--ink-faint)",
"var(--surface-raised)",
"var(--transition-fast)",
"var(--card-border)",
"var(--color-muted)",
"var(--color-text)",
]) {
expect(css).not.toContain(legacyReference);
}
expect(cssRule(css, ".dashboard-route")).toContain("--hv2-bg: var(--oc-bg-page)");
expect(cssRule(css, ".dashboard-route")).toContain("--hv2-radius-md: var(--oc-radius-surface)");
expect(sharedCss).toContain("--status-pending-bg: var(--oc-surface-interactive)");
expect(sharedCss).toContain("--status-pending-fg: var(--oc-text-muted)");
for (const sourcePath of [
"src/components/SignInPrompt.tsx",
"src/components/SkillOwnershipPanel.tsx",
"src/routes/import.tsx",
"src/routes/settings.tsx",
"src/routes/skills/publish.tsx",
]) {
expect(read(sourcePath)).not.toMatch(
/(?:text|bg|border)-(?:red|amber|emerald)-(?:\d+|\[[^\]]+\])/,
);
}
});
it("keeps Vercel browser instrumentation mounted outside local dev", () => {
const rootSource = rootRoute();
@@ -141,6 +242,8 @@ describe("restored UI design contract", () => {
expect(moreMenu).toContain("border-radius: var(--r-md)");
expect(css).toContain(".navbar-theme-switcher {\n --navbar-theme-ease");
expect(css).toContain("--navbar-theme-pad: 3px");
expect(css).toContain("--navbar-theme-outer-r: var(--oc-radius-control)");
expect(css).toContain("--navbar-theme-inner-r: var(--oc-radius-inset)");
expect(css).toContain("--navbar-theme-seg: 26px");
expect(css).toContain("height: var(--navbar-theme-collapsed-w)");
const mobileDrawerTheme = cssRule(css, ".mobile-nav-appearance-section .navbar-theme-switcher");
@@ -181,8 +284,20 @@ describe("restored UI design contract", () => {
it("requires the experiment hero and canonical home catalog without later sections", () => {
const homeSource = home();
const listingSource = read("src/components/HomeListingSection.tsx");
const appsSource = read("src/components/HomeAppsSection.tsx");
const publishersSource = read("src/components/HomePopularPublishersSection.tsx");
const css = styles();
expect(homeSource).toContain('className="home-v2-main oc-app-surface"');
expect(homeSource).toContain("home-v2-headline oc-hero-title");
expect(listingSource).toContain("home-v2-listing-card oc-card oc-card-interactive");
expect(listingSource).toContain("home-v2-listing-kind oc-segmented");
expect(appsSource).toContain("home-v2-apps-tile oc-card oc-card-interactive");
expect(appsSource).toContain('className="home-v2-apps-workflow-header"');
expect(appsSource).not.toContain('className="home-v2-apps-workflow-header oc-card"');
expect(publishersSource).toContain(
"home-v2-popular-publisher-card oc-card oc-card-interactive",
);
expect(homeSource).not.toContain("BUILT BY THE COMMUNITY");
expect(homeSource).not.toContain("Unleash.");
expect(homeSource).not.toContain("Ship.");
@@ -252,6 +367,7 @@ describe("restored UI design contract", () => {
it("keeps runtime requirement text high contrast in both themes", () => {
const css = styles();
const designTokens = read("node_modules/@openclaw/design-system/styles/tokens.css");
const installCardSource = read("src/components/SkillInstallCard.tsx");
expect(installCardSource).toContain("requirements-env-row");
@@ -262,12 +378,12 @@ describe("restored UI design contract", () => {
);
const darkRatio = contrastRatio(
tokenValue(css, ":root", "--ink"),
tokenValue(css, ":root", "--surface-muted"),
tokenValue(designTokens, ":root", "--oc-palette-ink-50"),
tokenValue(designTokens, ":root", "--oc-palette-ink-900"),
);
const lightRatio = contrastRatio(
tokenValue(css, '[data-theme-family="claw"][data-theme-resolved="light"]', "--ink"),
tokenValue(css, '[data-theme-family="claw"][data-theme-resolved="light"]', "--surface-muted"),
tokenValue(designTokens, ":root", "--oc-palette-paper-950"),
tokenValue(designTokens, ":root", "--oc-palette-paper-200"),
);
expect(darkRatio).toBeGreaterThanOrEqual(7);
@@ -302,10 +418,10 @@ describe("restored UI design contract", () => {
const css = styles();
expect(cssRule(css, ".navbar-search-typeahead-icon .marketplace-icon-user")).toContain(
"border-radius: 999px",
"border-radius: var(--oc-radius-round)",
);
expect(cssRule(css, ".navbar-search-typeahead-icon .marketplace-icon-org")).toContain(
"border-radius: 8px",
"border-radius: var(--oc-radius-inset)",
);
expect(
cssRule(css, ".navbar-search-typeahead-icon .marketplace-icon-user .marketplace-icon-image"),
+1 -1
View File
@@ -69,7 +69,7 @@ function DeploymentDriftBannerContent() {
return (
<div
role="alert"
className="mx-auto mt-4 w-[min(1100px,calc(100vw-32px))] rounded-[14px] border border-status-warning-fg/40 bg-status-warning-bg px-4 py-3 text-[0.95rem] leading-[1.4] text-status-warning-fg"
className="mx-auto mt-4 w-[min(1100px,calc(100vw-32px))] rounded-[var(--oc-radius-surface)] border border-status-warning-fg/40 bg-status-warning-bg px-4 py-3 text-[var(--oc-font-size-md)] leading-[1.4] text-status-warning-fg"
>
Deploy mismatch detected. Frontend expects backend build <code>{drift.expectedBuildSha}</code>{" "}
but Convex reports <code>{drift.actualBuildSha}</code>.
+1 -1
View File
@@ -153,7 +153,7 @@ export function DevPersonaFab() {
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-14 w-14 shrink-0 cursor-pointer items-center justify-center rounded-[9999px] border border-[color:var(--line)] bg-[color:var(--surface)] p-0 text-[color:var(--ink)] shadow-[var(--shadow)] transition-colors hover:bg-[color:var(--surface-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]"
className="flex h-14 w-14 shrink-0 cursor-pointer items-center justify-center rounded-[var(--oc-radius-round)] border border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-elevated)] p-0 text-[color:var(--oc-text-primary)] shadow-[var(--oc-shadow-md)] transition-colors hover:bg-[color:var(--oc-surface-interactive)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--oc-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--oc-bg-page)]"
aria-label="Open local dev personas"
>
<Wrench size={22} aria-hidden="true" />
+2 -2
View File
@@ -23,8 +23,8 @@ export function EmptyState({
children,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-[var(--radius-md)] border border-dashed border-[color:var(--line)] bg-[color:var(--surface)] px-6 py-14 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-[color:var(--surface-muted)]">
<div className="flex flex-col items-center justify-center gap-4 rounded-[var(--oc-radius-surface)] border border-dashed border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-surface-card)] px-6 py-14 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-[var(--oc-radius-inset)] bg-[color:var(--oc-bg-surface)]">
<Icon className="h-7 w-7 text-[color:var(--ink-soft)]" />
</div>
<div className="flex flex-col gap-1">
+1 -1
View File
@@ -73,7 +73,7 @@ function extractErrorMessage(error: unknown): string {
function ErrorFallback({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[rgba(255,107,74,0.12)]">
<div className="flex h-12 w-12 items-center justify-center rounded-[var(--oc-radius-inset)] bg-[color:var(--oc-surface-accent-soft)]">
<AlertTriangle className="h-6 w-6 text-[color:var(--accent)]" />
</div>
<div className="flex flex-col gap-1">
+19 -9
View File
@@ -60,7 +60,7 @@ function HomeAppsCompactSkill({ app }: { app: HomeSkillApp }) {
<Link
to="/skills"
search={{ ...SKILLS_BROWSE_SEARCH, q: app.browseQuery }}
className="home-v2-apps-tile"
className="home-v2-apps-tile oc-card oc-card-interactive"
title={app.description}
>
<span className="home-v2-apps-tile-icon" aria-hidden="true">
@@ -79,7 +79,7 @@ function HomeAppsCompactPlugin({ plugin: shortcut }: { plugin: HomePluginShortcu
return (
<Link
to={buildPluginDetailHref(shortcut.packageName)}
className="home-v2-apps-tile"
className="home-v2-apps-tile oc-card oc-card-interactive"
title={shortcut.description}
>
<span className="home-v2-apps-tile-icon" aria-hidden="true">
@@ -255,12 +255,14 @@ export function HomeAppsSection() {
}, [activeCategoryId]);
return (
<section className="home-v2-apps" aria-labelledby="home-v2-apps-title">
<section className="home-v2-apps oc-section" aria-labelledby="home-v2-apps-title">
<div className="home-v2-apps-stage">
<div className="home-v2-apps-workflow-header">
<div className="home-v2-apps-workflow-copy">
<h2 id="home-v2-apps-title">Skills for the apps you already use</h2>
<p>
<div className="home-v2-apps-workflow-copy oc-section-heading">
<h2 id="home-v2-apps-title" className="oc-section-title">
Skills for the apps you already use
</h2>
<p className="oc-section-copy">
Ready-made skills and gateway plugins that plug OpenClaw straight into your everyday
tools.
</p>
@@ -275,7 +277,11 @@ export function HomeAppsSection() {
</div>
</div>
<div className="home-v2-apps-categories" role="tablist" aria-label="App categories">
<div
className="home-v2-apps-categories oc-segmented"
role="tablist"
aria-label="App categories"
>
{appCategories.map((category) => {
const Icon = category.icon;
return (
@@ -284,7 +290,7 @@ export function HomeAppsSection() {
type="button"
role="tab"
aria-selected={category.id === activeCategoryId}
className="home-v2-apps-category-tab"
className="home-v2-apps-category-tab oc-segmented-item"
onClick={() => setActiveCategoryId(category.id)}
>
<Icon className="home-v2-apps-category-tab-icon" size={14} aria-hidden="true" />
@@ -305,7 +311,11 @@ export function HomeAppsSection() {
</div>
<div className="home-v2-apps-see-all-row">
<Link to="/skills" search={SKILLS_BROWSE_SEARCH} className="home-v2-apps-see-all">
<Link
to="/skills"
search={SKILLS_BROWSE_SEARCH}
className="home-v2-apps-see-all oc-action oc-action-ghost"
>
Browse all skills
<ArrowRight size={14} aria-hidden="true" />
</Link>
+12 -6
View File
@@ -178,19 +178,21 @@ export function HomeBringSkillsSection() {
return (
<section
className="home-v2-byos"
className="home-v2-byos oc-section"
aria-labelledby="home-v2-byos-title"
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
>
<ByosRevealBackdrop />
<div className="home-v2-byos-content">
<header className="home-v2-byos-head">
<span className="home-v2-byos-eyebrow">ClawHub CLI</span>
<h2 id="home-v2-byos-title" className="home-v2-byos-title">
<header className="home-v2-byos-head oc-section-heading">
<span className="home-v2-byos-eyebrow oc-eyebrow">ClawHub CLI</span>
<h2 id="home-v2-byos-title" className="home-v2-byos-title oc-section-title">
Bring your skills to ClawHub
</h2>
<p className="home-v2-byos-lede">Publish and sync your skills to ClawHub, your way.</p>
<p className="home-v2-byos-lede oc-section-copy">
Publish and sync your skills to ClawHub, your way.
</p>
</header>
<div className="home-v2-byos-tabs" role="tablist" aria-label="Choose an audience">
@@ -224,7 +226,11 @@ export function HomeBringSkillsSection() {
</div>
<div className="home-v2-byos-foot">
<Link to="/import" search={{ ownerHandle: undefined }} className="home-v2-byos-import">
<Link
to="/import"
search={{ ownerHandle: undefined }}
className="home-v2-byos-import oc-action oc-action-ghost"
>
<GitHubGlyph />
or import from your GitHub
<ArrowRight size={15} aria-hidden="true" />
+28 -12
View File
@@ -258,7 +258,9 @@ function HomeListingSkillCard({ entry, showStats }: { entry: SkillPageEntry; sho
return (
<Link
to={skillLink(entry)}
className={`home-v2-listing-card${showStats ? "" : " has-no-stats"}`}
className={`home-v2-listing-card oc-card oc-card-interactive${
showStats ? "" : " has-no-stats"
}`}
>
<div className="home-v2-listing-card-head">
<span className="home-v2-listing-card-icon" aria-hidden="true">
@@ -293,7 +295,7 @@ function HomeListingPluginCard({ plugin }: { plugin: PackageListItem }) {
const pluginHref = buildPluginDetailHref(plugin.name, { ownerHandle: plugin.ownerHandle });
return (
<Link to={pluginHref} className="home-v2-listing-card">
<Link to={pluginHref} className="home-v2-listing-card oc-card oc-card-interactive">
<div className="home-v2-listing-card-head">
<span className="home-v2-listing-card-icon" aria-hidden="true">
<MarketplaceIcon kind="plugin" label={name} size="sm" />
@@ -660,13 +662,19 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
};
return (
<section id="home-v2-listing" className="home-v2-listing" aria-label="Browse catalog">
<section
id="home-v2-listing"
className="home-v2-listing oc-section"
aria-label="Browse catalog"
>
<div className="home-v2-listing-controls">
<div className="home-v2-listing-toolbar">
<div className="home-v2-listing-kind" role="group" aria-label="Content type">
<div className="home-v2-listing-kind oc-segmented" role="group" aria-label="Content type">
<button
type="button"
className={`home-v2-listing-kind-btn${kind === "skills" ? " is-active" : ""}`}
className={`home-v2-listing-kind-btn oc-segmented-item${
kind === "skills" ? " is-active" : ""
}`}
aria-pressed={kind === "skills"}
onClick={() => handleKindChange("skills")}
>
@@ -674,7 +682,9 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
</button>
<button
type="button"
className={`home-v2-listing-kind-btn${kind === "plugins" ? " is-active" : ""}`}
className={`home-v2-listing-kind-btn oc-segmented-item${
kind === "plugins" ? " is-active" : ""
}`}
aria-pressed={kind === "plugins"}
onClick={() => handleKindChange("plugins")}
>
@@ -713,7 +723,9 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
<div className="home-v2-listing-actions-rail has-category">
<button
type="button"
className={`home-v2-listing-search-trigger${searchOpen ? " is-active" : ""}`}
className={`home-v2-listing-search-trigger oc-action oc-action-ghost oc-action-icon${
searchOpen ? " is-active" : ""
}`}
aria-label="Search catalog"
aria-expanded={searchOpen}
aria-controls="home-v2-listing-search-panel"
@@ -729,10 +741,12 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
onChange={setCategorySlugs}
/>
<div className="home-v2-listing-view" role="group" aria-label="Layout">
<div className="home-v2-listing-view oc-segmented" role="group" aria-label="Layout">
<button
type="button"
className={`home-v2-listing-view-btn${view === "list" ? " is-active" : ""}`}
className={`home-v2-listing-view-btn oc-segmented-item${
view === "list" ? " is-active" : ""
}`}
aria-pressed={view === "list"}
aria-label="List view"
onClick={() => setView("list")}
@@ -741,7 +755,9 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
</button>
<button
type="button"
className={`home-v2-listing-view-btn${view === "grid" ? " is-active" : ""}`}
className={`home-v2-listing-view-btn oc-segmented-item${
view === "grid" ? " is-active" : ""
}`}
aria-pressed={view === "grid"}
aria-label="Grid view"
onClick={() => setView("grid")}
@@ -790,7 +806,7 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
<button
key={category.slug}
type="button"
className="home-v2-listing-filter-chip"
className="home-v2-listing-filter-chip oc-pill"
onClick={() => removeCategory(category.slug)}
aria-label={`Remove ${category.label} category filter`}
>
@@ -800,7 +816,7 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
))
) : (
<>
<span className="home-v2-listing-filter-chip is-summary">
<span className="home-v2-listing-filter-chip oc-pill is-summary">
{selectedCategories.length} categories
</span>
<button
@@ -42,7 +42,7 @@ function PopularPublisherCard({
<Link
to="/$slug"
params={{ slug: pinned.handle }}
className="home-v2-popular-publisher-card"
className="home-v2-popular-publisher-card oc-card oc-card-interactive"
aria-label={`${name}, @${pinned.handle}`}
draggable={false}
>
@@ -140,13 +140,18 @@ export function HomePopularPublishersSection() {
};
return (
<section className="home-v2-popular-publishers" aria-labelledby="popular-publishers-title">
<header className="home-v2-popular-publishers-header">
<div className="home-v2-popular-publishers-heading">
<h2 id="popular-publishers-title">Popular creators</h2>
<p>Explore skills and plugins from standout builders.</p>
<section
className="home-v2-popular-publishers oc-section"
aria-labelledby="popular-publishers-title"
>
<header className="home-v2-popular-publishers-header oc-section-header">
<div className="home-v2-popular-publishers-heading oc-section-heading">
<h2 id="popular-publishers-title" className="oc-section-title">
Popular creators
</h2>
<p className="oc-section-copy">Explore skills and plugins from standout builders.</p>
</div>
<Link to="/creators" className="home-v2-popular-publishers-link">
<Link to="/creators" className="home-v2-popular-publishers-link oc-action oc-action-ghost">
Browse creators <ArrowRight size={14} aria-hidden="true" />
</Link>
</header>
+3 -3
View File
@@ -75,7 +75,7 @@ function PromotionCard({ promotion }: { promotion: PublicPromotion }) {
const isTencentPromotion = isTencentHyPromotion(promotion.title);
return (
<article className="home-v2-promotion-card">
<article className="home-v2-promotion-card oc-card">
<div className="home-v2-promotion-content">
<div className="home-v2-promotion-stack">
<h3 className="home-v2-promotion-title">
@@ -109,7 +109,7 @@ function PromotionCard({ promotion }: { promotion: PublicPromotion }) {
</span>
{ctaUrl ? (
<a
className="home-v2-promotion-link"
className="home-v2-promotion-link oc-action oc-action-primary"
href={ctaUrl}
target="_blank"
rel="noopener noreferrer"
@@ -167,7 +167,7 @@ export function HomePromotionsSection() {
if (promotions.length === 0) return null;
return (
<section className="home-v2-promotions" aria-labelledby="home-promotions-title">
<section className="home-v2-promotions oc-section" aria-labelledby="home-promotions-title">
<h2 id="home-promotions-title" className="sr-only">
Active promotions
</h2>
+5 -1
View File
@@ -5,7 +5,11 @@ export function OfficialTag({ className }: { className?: string }) {
return (
<Badge
variant="official"
className={className ? `official-tag rounded-full ${className}` : "official-tag rounded-full"}
className={
className
? `official-tag rounded-[var(--oc-radius-control)] ${className}`
: "official-tag rounded-[var(--oc-radius-control)]"
}
aria-label="Verified"
>
<BadgeCheck size={15} aria-hidden="true" className="official-badge-icon" />
+6 -6
View File
@@ -138,15 +138,15 @@ export function PackageSourceChooser(props: {
props.family === "code-plugin" && props.codePluginFieldIssues.length > 0;
const hasPackagePanelFooter = props.ignoredPaths.length > 0 || Boolean(props.validationError);
const selectedPackagePanelToneClass = isDragging
? "border-[color:var(--accent)] bg-[rgba(255,107,74,0.06)]"
? "border-[color:var(--oc-border-accent)] bg-[color:var(--oc-surface-accent-soft)]"
: isMetadataLocked
? "border-[color:var(--line)] bg-[color:var(--surface-muted)]"
: "border-emerald-300/45 bg-emerald-50/80 dark:border-emerald-500/30 dark:bg-emerald-950/25";
: "border-status-success-fg/35 bg-status-success-bg";
const selectedPackagePanelBgClass = isDragging
? "bg-[rgba(255,107,74,0.06)]"
? "bg-[color:var(--oc-surface-accent-soft)]"
: isMetadataLocked
? "bg-[color:var(--surface-muted)]"
: "bg-emerald-50/80 dark:bg-emerald-950/25";
: "bg-status-success-bg";
const setDirectoryInputRef = (node: HTMLInputElement | null) => {
directoryInputRef.current = node;
@@ -292,9 +292,9 @@ export function PackageSourceChooser(props: {
) : (
<Card className="mb-5">
<div
className={`relative flex flex-col items-center gap-4 overflow-hidden rounded-[var(--radius-md)] border-2 border-dashed p-8 text-center transition-colors ${
className={`relative flex flex-col items-center gap-4 overflow-hidden rounded-[var(--oc-radius-inset)] border-2 border-dashed p-8 text-center transition-colors ${
isDragging
? "border-[color:var(--accent)] bg-[rgba(255,107,74,0.06)]"
? "border-[color:var(--oc-border-accent)] bg-[color:var(--oc-surface-accent-soft)]"
: "border-[color:var(--line)] bg-[color:var(--surface-muted)]"
}`}
onDragOver={(event) => {
+1 -1
View File
@@ -235,7 +235,7 @@ export function PluginVersionsPanel({
})}
</div>
{loadMoreError ? (
<p className="mt-3 text-sm font-medium text-red-600 dark:text-red-400" role="alert">
<p className="mt-3 text-sm font-medium text-status-error-fg" role="alert">
{loadMoreError}
</p>
) : null}
+2 -2
View File
@@ -77,7 +77,7 @@ export function SignInPrompt({
}}
/>
<section className="relative z-10 mx-auto w-full max-w-[980px]">
<div className="relative isolate flex min-w-0 flex-col gap-6 overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] px-5 pb-10 pt-7 shadow-[0_18px_50px_rgba(0,0,0,0.16)] sm:flex-row sm:items-center sm:justify-between sm:px-8 sm:py-8 sm:pb-10">
<div className="relative isolate flex min-w-0 flex-col gap-6 overflow-hidden rounded-[var(--oc-radius-surface)] border border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-elevated)] px-5 pb-10 pt-7 shadow-[var(--oc-shadow-lg)] sm:flex-row sm:items-center sm:justify-between sm:px-8 sm:py-8 sm:pb-10">
<div className="min-w-0">
<span className="mb-4 inline-flex h-11 w-11 items-center justify-center rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] text-[color:var(--ink-soft)] sm:h-12 sm:w-12">
<Icon size={21} />
@@ -92,7 +92,7 @@ export function SignInPrompt({
) : null}
{visibleError ? (
<p
className="mt-3 rounded-[var(--radius-sm)] border border-red-300/40 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300"
className="mt-3 rounded-[var(--oc-radius-control)] border border-[color:color-mix(in_srgb,var(--oc-status-error-fg)_30%,var(--oc-border-subtle))] bg-[color:var(--oc-status-error-bg)] px-3 py-2 text-sm text-[color:var(--oc-status-error-fg)]"
role="alert"
>
<AuthErrorMessage message={visibleError} />{" "}
+11 -11
View File
@@ -716,17 +716,17 @@ function buildDiffOptions(
function applyMonacoTheme(monaco: NonNullable<ReturnType<typeof useMonaco>>) {
const styles = getComputedStyle(document.documentElement);
const surface = normalizeHex(styles.getPropertyValue("--surface").trim() || "#ffffff");
const surfaceMuted = styles.getPropertyValue("--surface-muted").trim() || "#f6f1ec";
const ink = styles.getPropertyValue("--ink").trim() || "#1d1a17";
const inkSoft = styles.getPropertyValue("--ink-soft").trim() || "#4c463f";
const line = styles.getPropertyValue("--line").trim() || "rgba(29, 26, 23, 0.12)";
const accent = styles.getPropertyValue("--accent").trim() || "#e65c46";
const seafoam = styles.getPropertyValue("--seafoam").trim() || "#2bc6a4";
const diffAdded = styles.getPropertyValue("--diff-added").trim() || "#9bb955";
const diffAddedStrong = styles.getPropertyValue("--diff-added-strong").trim() || seafoam;
const diffRemoved = styles.getPropertyValue("--diff-removed").trim() || "#e47866";
const diffRemovedStrong = styles.getPropertyValue("--diff-removed-strong").trim() || accent;
const surface = normalizeHex(styles.getPropertyValue("--oc-bg-elevated").trim() || "#ffffff");
const surfaceMuted = styles.getPropertyValue("--oc-bg-surface").trim() || "#f6f1ec";
const ink = styles.getPropertyValue("--oc-text-primary").trim() || "#1d1a17";
const inkSoft = styles.getPropertyValue("--oc-text-secondary").trim() || "#4c463f";
const line = styles.getPropertyValue("--oc-border-subtle").trim() || "rgba(29, 26, 23, 0.12)";
const accent = styles.getPropertyValue("--oc-accent-primary").trim() || "#e65c46";
const seafoam = styles.getPropertyValue("--oc-accent-secondary").trim() || "#2bc6a4";
const diffAdded = styles.getPropertyValue("--oc-diff-added").trim() || "#9bb955";
const diffAddedStrong = styles.getPropertyValue("--oc-diff-added-strong").trim() || seafoam;
const diffRemoved = styles.getPropertyValue("--oc-diff-removed").trim() || "#e47866";
const diffRemovedStrong = styles.getPropertyValue("--oc-diff-removed-strong").trim() || accent;
const diffDiagonal = toMonacoColor(
styles.getPropertyValue("--diff-diagonal").trim() || "#22222233",
);
+4 -6
View File
@@ -313,14 +313,14 @@ export function SkillOwnershipPanel({
</SettingsActionRow>
{canDeleteSkill ? (
<div className="mt-8 rounded-[var(--radius-md)] border border-red-300/50 bg-red-500/[0.035] p-4 dark:border-red-500/35 dark:bg-red-500/[0.045] sm:p-5">
<div className="mt-8 rounded-[var(--oc-radius-surface)] border border-status-error-fg/30 bg-status-error-bg p-4 sm:p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border border-red-300/40 bg-red-500/10 text-red-700 dark:border-red-500/30 dark:text-red-300">
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--oc-radius-inset)] border border-status-error-fg/30 bg-status-error-bg text-status-error-fg">
<ShieldAlert size={17} aria-hidden="true" />
</span>
<div className="min-w-0">
<h3 className="text-sm font-bold text-red-700 dark:text-red-300">Delete skill</h3>
<h3 className="text-sm font-bold text-status-error-fg">Delete skill</h3>
<p className="text-sm text-[color:var(--ink-soft)]">
Hide this skill from search, browse, and public install surfaces.
</p>
@@ -340,9 +340,7 @@ export function SkillOwnershipPanel({
</div>
) : null}
{error ? (
<p className="text-sm font-medium text-red-600 dark:text-red-400">{error}</p>
) : null}
{error ? <p className="text-sm font-medium text-status-error-fg">{error}</p> : null}
</div>
{/* Rename confirmation dialog */}
+2 -2
View File
@@ -142,7 +142,7 @@ export function SkillPublishSuccessDialog({
onInteractOutside={() => {
dismiss();
}}
className="[--discord-accent:#5865F2] [--publish-accent:var(--status-success-fg)] [display:block] w-[min(calc(100vw-2rem),620px)] overflow-hidden rounded-[calc(var(--radius-md)+8px)] border-[color:color-mix(in_srgb,var(--line)_88%,transparent)] bg-[color:var(--surface)] p-0 shadow-[0_34px_90px_-36px_rgba(0,0,0,0.62),0_0_0_1px_color-mix(in_srgb,var(--ink)_7%,transparent)] focus:outline-none sm:p-0"
className="[--discord-accent:#5865F2] [--publish-accent:var(--oc-status-success-fg)] [display:block] w-[min(calc(100vw-2rem),620px)] overflow-hidden rounded-[var(--oc-radius-surface)] border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-elevated)] p-0 shadow-[var(--oc-shadow-lg)] focus:outline-none sm:p-0"
style={{ display: "block" }}
>
<div className="relative w-full overflow-hidden">
@@ -234,7 +234,7 @@ export function SkillPublishSuccessDialog({
</div>
</div>
</div>
<div className="-mt-2 flex min-w-0 items-center gap-2 rounded-b-[var(--radius-md)] border border-t-0 border-[color:color-mix(in_srgb,var(--line)_74%,transparent)] bg-[color:color-mix(in_srgb,var(--surface)_82%,black)] px-3.5 pb-2 pt-4">
<div className="-mt-2 flex min-w-0 items-center gap-2 rounded-b-[var(--oc-radius-surface)] border border-t-0 border-[color:color-mix(in_srgb,var(--oc-border-subtle)_74%,transparent)] bg-[color:var(--oc-bg-surface)] px-3.5 pb-2 pt-4">
<div
className="h-2 w-2 shrink-0 rounded-full bg-[color:color-mix(in_srgb,var(--publish-accent)_42%,transparent)]"
aria-hidden="true"
+1 -1
View File
@@ -63,7 +63,7 @@ export function SkillReportDialog({
className="min-h-[120px]"
/>
{reportError ? (
<p className="text-sm font-medium text-red-600 dark:text-red-400" role="alert">
<p className="text-sm font-medium text-status-error-fg" role="alert">
{reportError}
</p>
) : null}
+1 -1
View File
@@ -289,7 +289,7 @@ export function ScanResultBadge({
return (
<Badge
variant={variant as BadgeProps["variant"]}
className={`min-h-0 rounded-[4px] px-2.5 py-0.5 text-[0.78rem] leading-[1.3] ${statusInfo.className}${className ? ` ${className}` : ""}`}
className={`min-h-0 rounded-[var(--oc-radius-control)] px-2.5 py-0.5 text-[var(--oc-font-size-xs)] leading-[1.3] ${statusInfo.className}${className ? ` ${className}` : ""}`}
>
{label ?? statusInfo.label}
</Badge>
+8 -3
View File
@@ -43,10 +43,15 @@ export function SkillShortSummaryField({
className="mr-auto flex min-w-0 items-center gap-1.5 text-xs leading-snug text-[color:var(--ink-soft)]"
role="note"
>
<Lightbulb className="size-3.5 shrink-0 text-[#0099ff]" aria-hidden />
<Lightbulb
className="size-3.5 shrink-0 text-[color:var(--oc-status-info-fg)]"
aria-hidden
/>
<span className="min-w-0">
<span className="font-semibold text-[#0099ff]">Pulled from your SKILL.md.</span> This
is what people see in cards and search.
<span className="font-semibold text-[color:var(--oc-status-info-fg)]">
Pulled from your SKILL.md.
</span>{" "}
This is what people see in cards and search.
</span>
<button
type="button"
+1 -1
View File
@@ -59,7 +59,7 @@ function DecorIcon({ active, icon: Icon, className }: DecorIconProps) {
return (
<span
data-active={active ? "true" : undefined}
className={`absolute hidden h-11 w-11 items-center justify-center rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)]/70 text-[color:var(--ink-soft)] opacity-45 shadow-sm data-[active=true]:animate-[upload-decor-jiggle_0.44s_ease-in-out_infinite] sm:flex ${className}`}
className={`absolute hidden h-11 w-11 items-center justify-center rounded-[var(--oc-radius-inset)] border border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-elevated)]/70 text-[color:var(--oc-text-secondary)] opacity-45 shadow-[var(--oc-shadow-sm)] data-[active=true]:animate-[upload-decor-jiggle_0.44s_ease-in-out_infinite] sm:flex ${className}`}
>
<Icon className="h-5 w-5" strokeWidth={1.8} />
</span>
@@ -23,7 +23,7 @@ export function BrowseResultsSkeleton({
className="card skill-card skill-card-spaced-footer"
>
<div className="skill-card-header">
<Skeleton className="h-[34px] w-[34px] rounded-[9px]" />
<Skeleton className="h-[34px] w-[34px] rounded-[var(--oc-radius-inset)]" />
<div className="skill-card-identity">
<Skeleton className="h-5 w-40 max-w-full" />
<Skeleton className="h-4 w-24 max-w-full" />
@@ -62,7 +62,7 @@ export function BrowseResultsSkeleton({
key={i}
className="skill-list-item skill-list-item-has-creator browse-results-skeleton-row"
>
<Skeleton className="browse-results-skeleton-icon h-9 w-9 shrink-0 rounded-[var(--r-sm)]" />
<Skeleton className="browse-results-skeleton-icon h-9 w-9 shrink-0 rounded-[var(--oc-radius-inset)]" />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
<Skeleton className="h-5 w-32 max-w-[45%]" />
+3 -3
View File
@@ -3,14 +3,14 @@ import * as React from "react";
import { cn } from "../../lib/utils";
const alertVariants = cva(
"relative w-full rounded-[var(--radius-sm)] border px-4 py-3 text-sm [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg+*]:pl-7",
"relative w-full rounded-[var(--oc-radius-control)] border px-4 py-3 text-sm [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg+*]:pl-7",
{
variants: {
variant: {
default:
"border-[color:var(--line)] bg-[color:var(--surface-muted)] text-[color:var(--ink)]",
info: "border-[color:color-mix(in_srgb,#6aa9ff_36%,var(--line))] bg-[color:color-mix(in_srgb,#6aa9ff_14%,transparent)] text-[#8fbdff]",
warn: "border-[color:color-mix(in_srgb,#f5c84b_38%,var(--line))] bg-[color:color-mix(in_srgb,#f5c84b_14%,transparent)] text-[#f5c84b]",
info: "border-[color:color-mix(in_srgb,var(--oc-status-info-fg)_32%,var(--oc-border-subtle))] bg-[color:var(--oc-status-info-bg)] text-[color:var(--oc-status-info-fg)]",
warn: "border-[color:color-mix(in_srgb,var(--oc-status-warning-fg)_32%,var(--oc-border-subtle))] bg-[color:var(--oc-status-warning-bg)] text-[color:var(--oc-status-warning-fg)]",
destructive:
"border-[color:var(--status-error-border,var(--line))] bg-[color:var(--status-error-bg)] text-[color:var(--status-error-fg)]",
},
+5 -2
View File
@@ -8,7 +8,10 @@ const Avatar = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-[var(--oc-radius-round)]",
className,
)}
{...props}
/>
));
@@ -33,7 +36,7 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-[color:var(--surface-muted)] text-sm font-semibold text-[color:var(--ink-soft)]",
"flex h-full w-full items-center justify-center rounded-[var(--oc-radius-round)] bg-[color:var(--oc-bg-surface)] text-sm font-semibold text-[color:var(--oc-text-secondary)]",
className,
)}
{...props}
+1 -1
View File
@@ -35,7 +35,7 @@ const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
variant === "warning" &&
"bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
variant === "review" &&
"bg-[color:color-mix(in_srgb,#6aa9ff_16%,transparent)] px-3 py-1 text-[#6aa9ff] border border-[color:color-mix(in_srgb,#6aa9ff_24%,var(--line))]",
"bg-[color:var(--oc-status-info-bg)] px-3 py-1 text-[color:var(--oc-status-info-fg)] border border-[color:color-mix(in_srgb,var(--oc-status-info-fg)_28%,var(--oc-border-subtle))]",
variant === "destructive" &&
"bg-status-error-bg px-3 py-1 text-status-error-fg border border-line",
size === "sm" &&
+2 -2
View File
@@ -5,8 +5,8 @@ import { cn } from "../../lib/utils";
const buttonVariants = cva(
[
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-[var(--oc-duration-ui)] ease-[var(--oc-ease-out)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--oc-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--oc-bg-page)]",
"cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-60",
"!no-underline hover:!no-underline",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
+1 -1
View File
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
ref={ref}
className={cn(
// Matches .card
"flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-space-5 transition-all duration-200 ease-out",
"flex w-full flex-col gap-3 rounded-[var(--oc-radius-surface)] border border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-surface-card)] p-space-5 transition-all duration-[var(--oc-duration-ui)] ease-[var(--oc-ease-out)]",
className,
)}
{...props}
+3 -3
View File
@@ -17,7 +17,7 @@ const DropdownMenuContent = React.forwardRef<
align={align}
data-slot="dropdown-menu-content"
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[180px] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[180px] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-[var(--oc-radius-surface)] border border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-elevated)] p-2 text-[color:var(--oc-text-primary)] shadow-[var(--oc-shadow-lg)]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
@@ -42,7 +42,7 @@ const DropdownMenuItem = React.forwardRef<
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-pointer select-none items-center gap-2 rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus-visible:outline-none data-[highlighted]:bg-[color:var(--surface-muted)] data-[highlighted]:text-[color:var(--ink)] focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset=true]:pl-8 data-[variant=destructive]:text-status-error-fg",
"relative flex cursor-pointer select-none items-center gap-2 rounded-[var(--oc-radius-control)] px-3 py-2 text-sm font-semibold text-[color:var(--oc-text-primary)] outline-none transition-colors focus-visible:outline-none data-[highlighted]:bg-[color:var(--oc-surface-interactive)] data-[highlighted]:text-[color:var(--oc-text-primary)] focus:bg-[color:var(--oc-surface-interactive)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset=true]:pl-8 data-[variant=destructive]:text-status-error-fg",
className,
)}
{...props}
@@ -58,7 +58,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
ref={ref}
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-pointer select-none items-center rounded-[var(--radius-sm)] py-2 pr-3 pl-8 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-[var(--oc-radius-control)] py-2 pr-3 pl-8 text-sm font-semibold text-[color:var(--oc-text-primary)] outline-none transition-colors focus:bg-[color:var(--oc-surface-interactive)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
+2 -2
View File
@@ -8,11 +8,11 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
type={type}
className={cn(
// Base styles
"w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"w-full min-h-[44px] rounded-[var(--oc-radius-control)] border px-3.5 py-space-3 text-[color:var(--oc-text-primary)] transition-all duration-[var(--oc-duration-fast)] ease-[var(--oc-ease-out)]",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
// Focus
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--oc-input-focus-ring)]",
// Disabled
"disabled:cursor-not-allowed disabled:opacity-60",
className,
+3 -3
View File
@@ -19,11 +19,11 @@ const SelectTrigger = React.forwardRef<
data-size={size}
className={cn(
// Matches form input token styling
"flex w-full min-h-[44px] cursor-pointer items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"flex w-full min-h-[44px] cursor-pointer items-center justify-between rounded-[var(--oc-radius-control)] border px-3.5 py-space-3 text-sm text-[color:var(--oc-text-primary)] transition-all duration-[var(--oc-duration-fast)] ease-[var(--oc-ease-out)]",
"data-[size=sm]:min-h-8 data-[size=sm]:px-2.5 data-[size=sm]:py-1 data-[size=sm]:text-xs",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--oc-input-focus-ring)]",
"disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
@@ -74,7 +74,7 @@ const SelectContent = React.forwardRef<
ref={ref}
data-slot="select-content"
className={cn(
"relative z-[90] max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
"relative z-[90] max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-hidden rounded-[var(--oc-radius-surface)] border border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-elevated)] text-[color:var(--oc-text-primary)] shadow-[var(--oc-shadow-lg)]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+1 -1
View File
@@ -41,7 +41,7 @@ const SheetContent = React.forwardRef<
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-80 flex flex-col gap-4 border-[color:var(--line)] bg-[color:var(--bg)] p-6 shadow-[var(--shadow)] transition-transform duration-300 ease-out",
"fixed z-80 flex flex-col gap-4 border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-page)] p-6 shadow-[var(--oc-shadow-lg)] transition-transform duration-[var(--oc-duration-ui)] ease-[var(--oc-ease-out)]",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 max-w-sm border-l data-[state=open]:animate-in data-[state=open]:slide-in-from-right data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right",
side === "left" &&
+2 -2
View File
@@ -9,11 +9,11 @@ const Textarea = React.forwardRef<
ref={ref}
className={cn(
// Base styles
"w-full min-h-[100px] resize-y rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"w-full min-h-[100px] resize-y rounded-[var(--oc-radius-control)] border px-3.5 py-space-3 text-[color:var(--oc-text-primary)] transition-all duration-[var(--oc-duration-fast)] ease-[var(--oc-ease-out)]",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
// Focus
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--oc-input-focus-ring)]",
// Disabled
"disabled:cursor-not-allowed disabled:opacity-60",
className,
+2 -2
View File
@@ -4,7 +4,7 @@ import * as React from "react";
import { cn } from "../../lib/utils";
const toggleGroupItemVariants = cva(
"inline-flex shrink-0 items-center justify-center rounded-[var(--radius-pill)] text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-fg [&_svg]:pointer-events-none [&_svg]:shrink-0",
"inline-flex shrink-0 items-center justify-center rounded-[var(--oc-radius-control)] text-[color:var(--oc-text-secondary)] transition-colors hover:text-[color:var(--oc-text-primary)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--oc-focus-ring)] disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-fg [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
@@ -48,7 +48,7 @@ const ToggleGroup = React.forwardRef<
data-variant={variant}
data-size={size}
className={cn(
"inline-flex h-[38px] items-center gap-0.5 rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[3px]",
"inline-flex h-[38px] items-center gap-0.5 rounded-[var(--oc-radius-control)] border border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-elevated)] p-[3px]",
className,
)}
{...props}
+1 -1
View File
@@ -15,7 +15,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] px-3 py-1.5 text-xs font-medium text-[color:var(--ink)] shadow-[var(--shadow)]",
"z-50 overflow-hidden rounded-[var(--oc-radius-control)] border border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-elevated)] px-3 py-1.5 text-xs font-medium text-[color:var(--oc-text-primary)] shadow-[var(--oc-shadow-md)]",
"animate-in fade-in-0 zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+212
View File
@@ -0,0 +1,212 @@
@import "@openclaw/design-system/tokens.css";
@import "@openclaw/design-system/themes.css";
@import "@openclaw/design-system/typography.css";
@import "@openclaw/design-system/themes/product.css";
@import "@openclaw/design-system/components.css";
@import "@openclaw/design-system/compat/clawhub.css";
/*
* Temporary aliases for legacy ClawHub styles that have not yet moved to the
* canonical token names. Values live here so old selectors cannot drift into
* a second theme system while the route CSS is migrated incrementally.
*/
:root {
--accent-soft: var(--oc-surface-accent-soft);
--card-border: var(--oc-border-subtle);
--color-muted: var(--oc-text-muted);
--color-text: var(--oc-text-primary);
--danger: var(--oc-status-error-fg);
--font-sans: var(--oc-font-body);
--ink-faint: var(--oc-text-muted);
--official-border: color-mix(in srgb, var(--oc-status-info-fg) 28%, var(--oc-border-subtle));
--shadow: var(--oc-shadow-md);
--shadow-card: var(--oc-shadow-sm);
--shadow-dialog: var(--oc-shadow-lg);
--shadow-hover: var(--oc-shadow-md);
--shadow-lg: var(--oc-shadow-lg);
--status-pending-bg: var(--oc-surface-interactive);
--status-pending-fg: var(--oc-text-muted);
--surface-raised: var(--oc-surface-interactive-hover);
--surface-subtle: var(--oc-surface-interactive);
--transition-fast: var(--oc-duration-fast) var(--oc-ease-out);
}
/*
* The landing page predates the shared package and still references --hv2-*
* variables throughout its layout CSS. Keep those aliases during migration,
* but make the canonical design system their only source of visual values.
*/
.home-v2-main.oc-app-surface {
--hv2-bg: var(--oc-bg-page);
--hv2-surface: var(--oc-surface-card);
--hv2-surface-hover: var(--oc-surface-interactive);
--hv2-border: var(--oc-border-subtle);
--hv2-border-hover: color-mix(in srgb, var(--oc-text-primary) 28%, var(--oc-border-subtle));
--hv2-border-strong: color-mix(in srgb, var(--oc-text-primary) 20%, var(--oc-border-subtle));
--hv2-text: var(--oc-text-primary);
--hv2-text-secondary: var(--oc-text-secondary);
--hv2-text-tertiary: var(--oc-text-muted);
--hv2-accent: var(--oc-accent-primary);
--hv2-accent-fill: var(--oc-surface-accent-soft);
--hv2-accent-fill-hover: color-mix(
in srgb,
var(--oc-surface-accent-soft) 78%,
var(--oc-accent-primary)
);
--hv2-accent-glow: color-mix(in srgb, var(--oc-accent-primary) 12%, transparent);
--hv2-accent-border: var(--oc-border-accent);
--hv2-accent-border-hover: color-mix(in srgb, var(--oc-accent-primary) 68%, transparent);
--hv2-green: var(--oc-accent-secondary);
--hv2-green-fill: var(--oc-surface-secondary-soft);
--hv2-green-border: color-mix(in srgb, var(--oc-accent-secondary) 38%, transparent);
--hv2-green-border-hover: color-mix(in srgb, var(--oc-accent-secondary) 62%, transparent);
--hv2-green-fill-hover: color-mix(
in srgb,
var(--oc-surface-secondary-soft) 72%,
var(--oc-accent-secondary)
);
--hv2-radius-sm: var(--oc-radius-control);
--hv2-radius-md: var(--oc-radius-surface);
--hv2-radius-lg: var(--oc-radius-surface);
--hv2-ease-out: var(--oc-ease-out);
--hv2-duration-fast: var(--oc-duration-fast);
--hv2-duration-ui: var(--oc-duration-ui);
}
.home-v2-main .oc-card {
border-color: var(--oc-component-border);
border-radius: var(--oc-radius-surface);
background: var(--oc-component-surface);
color: var(--oc-text-primary);
box-shadow: var(--oc-component-shadow);
}
.home-v2-main .oc-card-interactive:hover,
.home-v2-main .oc-card-interactive:focus-visible {
border-color: var(--oc-component-border-hover);
background: var(--oc-component-surface-hover);
box-shadow: var(--oc-component-shadow-hover);
}
.home-v2-main .oc-action-primary {
border-color: var(--oc-accent-primary);
background: var(--oc-accent-primary);
color: var(--oc-text-on-accent);
}
.home-v2-main .oc-action-primary:hover,
.home-v2-main .oc-action-primary:focus-visible {
border-color: var(--oc-accent-primary-hover);
background: var(--oc-accent-primary-hover);
color: var(--oc-text-on-accent);
}
.home-v2-main .oc-segmented {
border-color: var(--oc-border-subtle);
border-radius: var(--oc-radius-control);
background: var(--oc-surface-card);
}
.home-v2-main .oc-segmented-item {
border-radius: var(--oc-radius-inset);
}
.home-v2-main .oc-segmented-item[aria-selected="true"],
.home-v2-main .oc-segmented-item[aria-pressed="true"],
.home-v2-main .oc-segmented-item.is-active {
background: var(--oc-surface-interactive-hover);
color: var(--oc-text-primary);
}
@media (max-width: 760px) {
.home-v2-main .home-v2-popular-publishers-header.oc-section-header {
align-items: flex-start;
flex-direction: column;
gap: var(--oc-space-3);
}
}
/*
* The legacy light selectors in styles.css are more specific than the
* compatibility aliases' :root rule. Rebind those aliases at the same
* specificity while keeping every value owned by the shared package.
*/
[data-theme-family="claw"][data-theme-resolved="light"] {
--bg: var(--oc-bg-page);
--bg-soft: var(--oc-bg-surface);
--surface: var(--oc-bg-elevated);
--surface-muted: var(--oc-bg-surface);
--nav-bg: var(--oc-bg-page);
--ink: var(--oc-text-primary);
--ink-soft: var(--oc-text-secondary);
--accent: var(--oc-accent-primary);
--accent-fg: var(--oc-text-on-accent);
--accent-deep: var(--oc-accent-primary-hover);
--accent-subtle: var(--oc-surface-accent-soft);
--line: var(--oc-border-subtle);
--border-ui: var(--oc-border-subtle);
--border-ui-hover: color-mix(in srgb, var(--oc-text-primary) 20%, transparent);
--border-ui-active: var(--oc-border-accent);
--status-success-bg: var(--oc-status-success-bg);
--status-success-fg: var(--oc-status-success-fg);
--status-warning-bg: var(--oc-status-warning-bg);
--status-warning-fg: var(--oc-status-warning-fg);
--status-error-bg: var(--oc-status-error-bg);
--status-error-fg: var(--oc-status-error-fg);
--official-bg: var(--oc-status-info-bg);
--official-fg: var(--oc-status-info-fg);
--input-border: var(--oc-input-border);
--input-bg: var(--oc-input-bg);
--input-placeholder: var(--oc-input-placeholder);
--input-focus-border: var(--oc-input-focus-border);
--input-focus-ring: var(--oc-input-focus-ring);
--label-fg: var(--oc-text-secondary);
--hover-bg: var(--oc-surface-interactive);
--active-bg: var(--oc-surface-accent-soft);
--overlay-bg: var(--oc-surface-overlay);
--diff-added: var(--oc-diff-added);
--diff-added-strong: var(--oc-diff-added-strong);
--diff-removed: var(--oc-diff-removed);
--diff-removed-strong: var(--oc-diff-removed-strong);
}
@media (prefers-color-scheme: light) {
[data-theme-family="claw"][data-theme-mode="system"] {
--bg: var(--oc-bg-page);
--bg-soft: var(--oc-bg-surface);
--surface: var(--oc-bg-elevated);
--surface-muted: var(--oc-bg-surface);
--nav-bg: var(--oc-bg-page);
--ink: var(--oc-text-primary);
--ink-soft: var(--oc-text-secondary);
--accent: var(--oc-accent-primary);
--accent-fg: var(--oc-text-on-accent);
--accent-deep: var(--oc-accent-primary-hover);
--accent-subtle: var(--oc-surface-accent-soft);
--line: var(--oc-border-subtle);
--border-ui: var(--oc-border-subtle);
--border-ui-hover: color-mix(in srgb, var(--oc-text-primary) 20%, transparent);
--border-ui-active: var(--oc-border-accent);
--status-success-bg: var(--oc-status-success-bg);
--status-success-fg: var(--oc-status-success-fg);
--status-warning-bg: var(--oc-status-warning-bg);
--status-warning-fg: var(--oc-status-warning-fg);
--status-error-bg: var(--oc-status-error-bg);
--status-error-fg: var(--oc-status-error-fg);
--official-bg: var(--oc-status-info-bg);
--official-fg: var(--oc-status-info-fg);
--input-border: var(--oc-input-border);
--input-bg: var(--oc-input-bg);
--input-placeholder: var(--oc-input-placeholder);
--input-focus-border: var(--oc-input-focus-border);
--input-focus-ring: var(--oc-input-focus-ring);
--label-fg: var(--oc-text-secondary);
--hover-bg: var(--oc-surface-interactive);
--active-bg: var(--oc-surface-accent-soft);
--overlay-bg: var(--oc-surface-overlay);
--diff-added: var(--oc-diff-added);
--diff-added-strong: var(--oc-diff-added-strong);
--diff-removed: var(--oc-diff-removed);
--diff-removed-strong: var(--oc-diff-removed-strong);
}
}
+5 -9
View File
@@ -176,12 +176,12 @@
.publisher-profile-member-avatar-stack-item .marketplace-icon,
.publisher-profile-member-menu-row .marketplace-icon,
.publisher-profile-meta-chip .marketplace-icon-user {
border-radius: 999px;
border-radius: var(--oc-radius-round);
}
.publisher-profile-chrome-identity .publisher-profile-avatar .marketplace-icon-org,
.publisher-profile-meta-chip .marketplace-icon-org {
border-radius: var(--r-md);
border-radius: var(--oc-radius-inset);
}
.publisher-profile-chrome-identity
@@ -536,17 +536,13 @@
max-height: min(20rem, 56vh);
overflow: hidden !important;
border: 1px solid var(--line) !important;
border-radius: 16px !important;
border-radius: var(--oc-radius-surface) !important;
background: var(--surface) !important;
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.24),
0 0 0 1px color-mix(in srgb, var(--ink) 4%, transparent) !important;
box-shadow: var(--oc-shadow-lg) !important;
}
[data-theme-resolved="light"] .publisher-profile-members-menu {
box-shadow:
0 14px 36px rgba(0, 0, 0, 0.1),
0 0 0 1px var(--line) !important;
box-shadow: var(--oc-shadow-lg) !important;
}
.publisher-profile-members-menu-list {
+3 -1
View File
@@ -425,7 +425,9 @@ export function PromotionsPage({
/>
</PromotionField>
</div>
{formError ? <p className="section-subtitle m-0 mt-3 text-red-500">{formError}</p> : null}
{formError ? (
<p className="section-subtitle m-0 mt-3 text-status-error-fg">{formError}</p>
) : null}
<div className="mt-4 flex items-center gap-2">
<Button type="button" onClick={submit} disabled={submitting}>
{editingSlug ? "Save changes" : "Create draft"}
+2 -1
View File
@@ -501,7 +501,8 @@ describe("Settings", () => {
expect(screen.getByRole("heading", { name: "Delete synced repo & skills" })).toBeTruthy();
expect(screen.getByText(/This will delete the sync job for this repo/i)).toBeTruthy();
const deleteButton = screen.getByRole("button", { name: "Delete" });
expect(deleteButton.className).toMatch(/border-red/);
expect(deleteButton.className).toContain("bg-status-error-bg");
expect(deleteButton.className).toContain("text-status-error-fg");
fireEvent.click(deleteButton);
+10 -5
View File
@@ -24,6 +24,7 @@ import {
} from "../lib/authErrorMessage";
import { getClawHubSiteUrl, SITE_DESCRIPTION, SITE_NAME } from "../lib/site";
import { getThemeModeFromCookieHeader, normalizeThemeMode } from "../lib/themeCookie";
import designSystemCss from "../design-system.css?url";
import appCss from "../styles.css?url";
const OG_IMAGE_VERSION = "20260624-1";
@@ -120,6 +121,10 @@ export const Route = createRootRoute({
rel: "stylesheet",
href: appCss,
},
{
rel: "stylesheet",
href: designSystemCss,
},
{
rel: "icon",
href: "/favicon.ico",
@@ -200,11 +205,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
position="bottom-right"
toastOptions={{
style: {
background: "var(--surface)",
color: "var(--ink)",
border: "1px solid var(--line)",
borderRadius: "var(--radius-md)",
fontFamily: "var(--font-body)",
background: "var(--oc-bg-elevated)",
color: "var(--oc-text-primary)",
border: "1px solid var(--oc-border-subtle)",
borderRadius: "var(--oc-radius-control)",
fontFamily: "var(--oc-font-body)",
},
}}
/>
+2 -2
View File
@@ -139,7 +139,7 @@ export function AddPage() {
type="button"
className={`flex min-h-11 items-center justify-center gap-2 rounded-[var(--radius-sm)] px-3 text-sm font-semibold transition-colors ${
kind === "skill"
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
? "bg-[color:var(--oc-bg-elevated)] text-[color:var(--oc-text-primary)] shadow-[var(--oc-shadow-sm)]"
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
aria-pressed={kind === "skill"}
@@ -152,7 +152,7 @@ export function AddPage() {
type="button"
className={`flex min-h-11 items-center justify-center gap-2 rounded-[var(--radius-sm)] px-3 text-sm font-semibold transition-colors ${
kind === "plugin"
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
? "bg-[color:var(--oc-bg-elevated)] text-[color:var(--oc-text-primary)] shadow-[var(--oc-shadow-sm)]"
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
}`}
aria-pressed={kind === "plugin"}
+4 -4
View File
@@ -768,13 +768,13 @@ export function ImportGitHub() {
) : null}
{repoListError ? (
<div className="rounded-[var(--radius-sm)] border border-red-300/40 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300">
<div className="rounded-[var(--oc-radius-control)] border border-status-error-fg/30 bg-status-error-bg px-3 py-2 text-sm text-status-error-fg">
{repoListError}
</div>
) : null}
{error ? (
<div className="rounded-[var(--radius-sm)] border border-red-300/40 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300">
<div className="rounded-[var(--oc-radius-control)] border border-status-error-fg/30 bg-status-error-bg px-3 py-2 text-sm text-status-error-fg">
{error}
</div>
) : null}
@@ -873,7 +873,7 @@ export function ImportGitHub() {
) : isReviewing && !isReviewLoading ? (
<section className="flex flex-col gap-4">
{error ? (
<div className="rounded-[var(--radius-sm)] border border-red-300/40 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300">
<div className="rounded-[var(--oc-radius-control)] border border-status-error-fg/30 bg-status-error-bg px-3 py-2 text-sm text-status-error-fg">
{error}
</div>
) : null}
@@ -1390,7 +1390,7 @@ function ReviewSkillCard({
</div>
{issues.length > 0 ? (
<div className="rounded-[var(--radius-sm)] border border-red-300/40 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300">
<div className="rounded-[var(--oc-radius-control)] border border-status-error-fg/30 bg-status-error-bg px-3 py-2 text-sm text-status-error-fg">
{issues[0]}
{slugCollision?.url ? (
<>
+5 -5
View File
@@ -337,12 +337,12 @@ function SkillsHome() {
};
return (
<main className="home-v2-main">
<main className="home-v2-main oc-app-surface">
<HomeV2FoldBottomFade />
<canvas ref={confettiRef} className="home-v2-confetti" style={{ display: "none" }} />
{/* ═══ HERO ═══ */}
<section className="home-v2-hero">
<section className="home-v2-hero oc-hero">
<div className="home-v2-hero-bg">
<div className="home-v2-glow" />
<div className="home-v2-dots" />
@@ -353,7 +353,7 @@ function SkillsHome() {
{slotState ? (
<h1
className={`home-v2-headline home-v2-headline-slots${
className={`home-v2-headline home-v2-headline-slots oc-hero-title${
slotState.phase === "stopped" && slotState.won
? slotState.isHackJackpot
? " home-v2-headline-jackpot home-v2-headline-hack"
@@ -385,7 +385,7 @@ function SkillsHome() {
</button>
</h1>
) : (
<h1 className="home-v2-headline">
<h1 className="home-v2-headline oc-hero-title">
<button
type="button"
className="home-v2-headline-trigger"
@@ -411,7 +411,7 @@ function SkillsHome() {
</h1>
)}
<p className="home-v2-sub">Discover skills and plugins from top creators</p>
<p className="home-v2-sub oc-hero-lede">Discover skills and plugins from top creators</p>
</section>
<HomePromotionsSection />
+37 -42
View File
@@ -831,7 +831,7 @@ export function Settings() {
</Field>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
{hasProfileChanges ? (
<span className="text-sm font-semibold text-red-700 dark:text-red-300">
<span className="text-sm font-semibold text-status-error-fg">
You have unsaved changes.
</span>
) : null}
@@ -1003,10 +1003,7 @@ export function Settings() {
</Field>
</div>
{createOrgError ? (
<p
className="text-sm font-medium text-red-600 dark:text-red-400"
role="alert"
>
<p className="text-sm font-medium text-status-error-fg" role="alert">
{createOrgError}
</p>
) : null}
@@ -1108,7 +1105,7 @@ export function Settings() {
</div>
<div className="flex flex-col gap-3 lg:col-span-2 lg:flex-row lg:items-center lg:justify-end">
{hasOrgProfileChanges ? (
<span className="text-sm font-semibold text-red-700 dark:text-red-300">
<span className="text-sm font-semibold text-status-error-fg">
You have unsaved changes.
</span>
) : null}
@@ -1204,7 +1201,7 @@ export function Settings() {
</div>
{inviteError ? (
<p
className="text-sm font-medium text-red-600 dark:text-red-400"
className="text-sm font-medium text-status-error-fg"
role="alert"
>
{inviteError}
@@ -1310,11 +1307,11 @@ export function Settings() {
<SettingsBlock tone="danger">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border border-red-300/40 bg-red-500/10 text-red-700 dark:border-red-500/30 dark:text-red-300">
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--oc-radius-inset)] border border-status-error-fg/30 bg-status-error-bg text-status-error-fg">
<ShieldAlert size={17} />
</span>
<div className="min-w-0">
<h3 className="text-sm font-bold text-red-700 dark:text-red-300">
<h3 className="text-sm font-bold text-status-error-fg">
Delete organization
</h3>
<p className="text-sm text-[color:var(--ink-soft)]">
@@ -1435,10 +1432,7 @@ export function Settings() {
</Field>
</div>
{createOrgError ? (
<p
className="text-sm font-medium text-red-600 dark:text-red-400"
role="alert"
>
<p className="text-sm font-medium text-status-error-fg" role="alert">
{createOrgError}
</p>
) : null}
@@ -1568,8 +1562,8 @@ export function Settings() {
</SettingsBlock>
{newToken ? (
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-amber-300/30 bg-amber-500/[0.06] p-4 dark:border-amber-500/25 dark:bg-amber-500/[0.08]">
<div className="flex items-center gap-2 text-sm font-semibold text-amber-800 dark:text-amber-300">
<div className="flex flex-col gap-3 rounded-[var(--oc-radius-surface)] border border-status-warning-fg/30 bg-status-warning-bg p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-status-warning-fg">
<ShieldAlert size={16} />
Copy this token now it will not be shown again.
</div>
@@ -1714,13 +1708,10 @@ export function Settings() {
</Dialog>
</div>
<div className="flex items-start gap-3 rounded-[var(--radius-sm)] border border-red-300/20 bg-red-500/[0.04] p-4 dark:border-red-500/20 dark:bg-red-500/[0.06]">
<ShieldAlert
size={18}
className="mt-0.5 shrink-0 text-red-600 dark:text-red-400"
/>
<div className="flex items-start gap-3 rounded-[var(--oc-radius-control)] border border-status-error-fg/25 bg-status-error-bg p-4">
<ShieldAlert size={18} className="mt-0.5 shrink-0 text-status-error-fg" />
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold text-red-700 dark:text-red-300">
<p className="text-sm font-semibold text-status-error-fg">
This will permanently delete your account
</p>
<p className="text-sm text-[color:var(--ink-soft)]">
@@ -1794,11 +1785,9 @@ function DeletionResourceSummary({
: emptyLabel;
return (
<div className="overflow-hidden rounded-[var(--radius-sm)] border border-red-300/40 bg-red-500/5 text-sm dark:border-red-500/30">
<div className="border-b border-red-300/30 px-3 py-2 dark:border-red-500/25">
<p className="font-semibold text-red-700 dark:text-red-300">
Resources permanently deleted
</p>
<div className="overflow-hidden rounded-[var(--oc-radius-control)] border border-status-error-fg/30 bg-status-error-bg text-sm">
<div className="border-b border-status-error-fg/25 px-3 py-2">
<p className="font-semibold text-status-error-fg">Resources permanently deleted</p>
<p className="mt-1 text-[color:var(--ink-soft)]">{summary}</p>
</div>
{resources.length ? (
@@ -2055,8 +2044,8 @@ function SettingsSection({
<span
className={`mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border ${
tone === "danger"
? "border-red-300/40 bg-red-500/10 text-red-700 dark:border-red-500/30 dark:text-red-300"
: "border-[color:var(--line)] bg-[color:var(--surface-muted)] text-[color:var(--ink)]"
? "border-status-error-fg/30 bg-status-error-bg text-status-error-fg"
: "border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-surface)] text-[color:var(--oc-text-primary)]"
}`}
>
{icon}
@@ -2064,7 +2053,9 @@ function SettingsSection({
<div className="min-w-0">
<h2
className={`font-display text-2xl font-black leading-none ${
tone === "danger" ? "text-red-700 dark:text-red-300" : "text-[color:var(--ink)]"
tone === "danger"
? "text-status-error-fg"
: "text-[color:var(--oc-text-primary)]"
}`}
>
{title}
@@ -2093,7 +2084,7 @@ function SettingsBlock({
<div
className={`flex min-w-0 flex-col gap-4 rounded-[var(--radius-md)] border p-4 sm:p-5 ${
tone === "danger"
? "border-red-300/50 bg-red-500/[0.035] dark:border-red-500/35 dark:bg-red-500/[0.045]"
? "border-status-error-fg/30 bg-status-error-bg"
: "border-[color:var(--line)] bg-[color:var(--surface)]"
} ${className}`}
>
@@ -2256,9 +2247,9 @@ function GitHubSourceList({
</div>
<Button
type="button"
variant="outline"
variant="destructive"
loading={deletingSourceId === source._id}
className="shrink-0 border-red-500/45 text-red-700 hover:not-disabled:border-red-500 hover:not-disabled:bg-red-500/10 dark:border-red-500/35 dark:text-red-300"
className="shrink-0"
onClick={() => onDeleteSource(source)}
>
Delete
@@ -2398,7 +2389,7 @@ function GitHubSourceHealth({ source }: { source: GitHubSkillSource }) {
</GitHubSourceOverviewRow>
</div>
{needsAttention && latestError ? (
<p className="border-t border-[color:var(--line)] px-3 py-2 text-sm text-red-700 dark:text-red-300">
<p className="border-t border-[color:var(--oc-border-subtle)] px-3 py-2 text-sm text-status-error-fg">
<span className="font-semibold">Latest error:</span> {latestError}
</p>
) : null}
@@ -2416,7 +2407,7 @@ function GitHubSourceSyncIssues({ source }: { source: GitHubSkillSource }) {
<span className="text-xs font-semibold uppercase tracking-[0.08em] text-[color:var(--ink-soft)]">
Sync issues
</span>
<span className="inline-flex h-5 items-center rounded-full border border-red-500/35 bg-red-500/10 px-2 text-[11px] font-semibold text-red-700 dark:text-red-300">
<span className="inline-flex h-5 items-center rounded-[var(--oc-radius-control)] border border-status-error-fg/30 bg-status-error-bg px-2 text-[11px] font-semibold text-status-error-fg">
{issues.length}
</span>
</div>
@@ -2435,7 +2426,7 @@ function GitHubSourceSyncIssues({ source }: { source: GitHubSkillSource }) {
{formatGitHubSourceIssueKind(skill.kind)}
</div>
</div>
<div className="shrink-0 text-left text-xs font-semibold text-red-700 dark:text-red-300 sm:max-w-[40%] sm:text-right">
<div className="shrink-0 text-left text-xs font-semibold text-status-error-fg sm:max-w-[40%] sm:text-right">
{skill.message}
</div>
</div>
@@ -2489,10 +2480,10 @@ function GitHubSourceOverviewRow({ label, children }: { label: string; children:
function GitHubSourceStatusPill({ needsAttention }: { needsAttention: boolean }) {
return (
<span
className={`inline-flex h-6 items-center rounded-full border px-2.5 text-xs font-semibold ${
className={`inline-flex h-6 items-center rounded-[var(--oc-radius-control)] border px-2.5 text-xs font-semibold ${
needsAttention
? "border-red-500/35 bg-red-500/10 text-red-700 dark:text-red-300"
: "border-emerald-500/35 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
? "border-status-error-fg/30 bg-status-error-bg text-status-error-fg"
: "border-status-success-fg/30 bg-status-success-bg text-status-success-fg"
}`}
>
{needsAttention ? "Needs attention" : "Healthy"}
@@ -2629,7 +2620,11 @@ function TokenList({
<td className="py-4 align-middle">
<div className="flex min-w-0 items-center gap-3">
{token.revokedAt ? (
<CircleX size={16} aria-hidden="true" className="shrink-0 text-red-500" />
<CircleX
size={16}
aria-hidden="true"
className="shrink-0 text-status-error-fg"
/>
) : (
<Code
size={16}
@@ -2672,7 +2667,7 @@ function TokenList({
size="sm"
type="button"
onClick={() => onRevoke?.(token._id)}
className="h-8 gap-2 px-0 text-xs text-red-700 hover:bg-transparent hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
className="h-8 gap-2 px-0 text-xs text-status-error-fg hover:bg-transparent hover:opacity-80"
>
<Trash2 size={14} />
Revoke
@@ -2693,7 +2688,7 @@ function TokenList({
>
<div className="flex min-w-0 items-center gap-2">
{token.revokedAt ? (
<CircleX size={16} aria-hidden="true" className="shrink-0 text-red-500" />
<CircleX size={16} aria-hidden="true" className="shrink-0 text-status-error-fg" />
) : (
<Code
size={16}
@@ -2746,7 +2741,7 @@ function TokenList({
size="sm"
type="button"
onClick={() => onRevoke?.(token._id)}
className="h-8 gap-2 px-0 text-xs text-red-700 hover:bg-transparent hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
className="h-8 gap-2 px-0 text-xs text-status-error-fg hover:bg-transparent hover:opacity-80"
>
<Trash2 size={14} />
Revoke
+1 -1
View File
@@ -263,7 +263,7 @@ export function SkillsIndex() {
);
return (
<main className="browse-page browse-page-borderless-header">
<main className="browse-page browse-page-borderless-header skills-browse-page">
<div className="browse-page-header">
<div className="browse-page-header-main">
<h1 className="browse-title">
+2 -2
View File
@@ -1311,7 +1311,7 @@ export function Upload() {
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-2">
{error ? (
<div className="text-sm font-medium text-red-600 dark:text-red-400" role="alert">
<div className="text-sm font-medium text-status-error-fg" role="alert">
{error}
</div>
) : null}
@@ -1344,7 +1344,7 @@ export function Upload() {
function InlineValidationMessage(props: { id: string; message?: string }) {
if (!props.message) return null;
return (
<p id={props.id} className="text-sm font-medium text-red-600 dark:text-red-400">
<p id={props.id} className="text-sm font-medium text-status-error-fg">
{props.message}
</p>
);
+2 -2
View File
@@ -199,7 +199,7 @@ export function Stars() {
handleUnstar(skill);
}}
aria-label={`Unstar ${skill.displayName}`}
className="stars-card-unstar text-[color:var(--gold)] hover:text-red-500"
className="stars-card-unstar text-[color:var(--gold)] hover:text-status-error-fg"
>
<Star className="h-4 w-4 fill-current" />
</Button>
@@ -221,7 +221,7 @@ export function Stars() {
handleUnstar(skill);
}}
aria-label={`Unstar ${skill.displayName}`}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[color:var(--gold)] hover:text-red-500"
className="absolute right-3 top-1/2 -translate-y-1/2 text-[color:var(--gold)] hover:text-status-error-fg"
>
<Star className="h-4 w-4 fill-current" />
</Button>
+1 -1
View File
@@ -374,7 +374,7 @@ function PublisherProfileMembers({
<DropdownMenuContent
align="start"
sideOffset={6}
className="publisher-profile-members-menu !overflow-hidden !rounded-2xl p-0 shadow-none"
className="publisher-profile-members-menu !overflow-hidden !rounded-[var(--oc-radius-surface)] p-0 shadow-none"
aria-label={`${members.length} members`}
>
<div className="publisher-profile-members-menu-list">
+357 -382
View File
File diff suppressed because it is too large Load Diff