From c251ead206c56e6a067f8181f0133b40eb5c8e0a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 22 Jul 2026 13:49:01 +0000 Subject: [PATCH] feat(css): add framework-agnostic design system --- bun.lock | 6 + packages/css/LICENSE | 21 ++ packages/css/README.md | 156 ++++++++++++++ packages/css/base.css | 32 +++ packages/css/colors.css | 172 ++++++++++++++++ packages/css/components.css | 9 + packages/css/components/badge.css | 33 +++ packages/css/components/button.css | 246 +++++++++++++++++++++++ packages/css/components/check.css | 20 ++ packages/css/components/divider.css | 11 + packages/css/components/field.css | 24 +++ packages/css/components/input.css | 55 +++++ packages/css/components/page.css | 64 ++++++ packages/css/components/surface.css | 38 ++++ packages/css/components/table.css | 52 +++++ packages/css/examples/internal-tool.html | 99 +++++++++ packages/css/index.css | 3 + packages/css/package.json | 45 +++++ packages/css/script/publish.ts | 24 +++ packages/css/test/package.test.ts | 63 ++++++ packages/css/theme.css | 107 ++++++++++ packages/css/tokens.css | 2 + script/publish.ts | 3 + 23 files changed, 1285 insertions(+) create mode 100644 packages/css/LICENSE create mode 100644 packages/css/README.md create mode 100644 packages/css/base.css create mode 100644 packages/css/colors.css create mode 100644 packages/css/components.css create mode 100644 packages/css/components/badge.css create mode 100644 packages/css/components/button.css create mode 100644 packages/css/components/check.css create mode 100644 packages/css/components/divider.css create mode 100644 packages/css/components/field.css create mode 100644 packages/css/components/input.css create mode 100644 packages/css/components/page.css create mode 100644 packages/css/components/surface.css create mode 100644 packages/css/components/table.css create mode 100644 packages/css/examples/internal-tool.html create mode 100644 packages/css/index.css create mode 100644 packages/css/package.json create mode 100644 packages/css/script/publish.ts create mode 100644 packages/css/test/package.test.ts create mode 100644 packages/css/theme.css create mode 100644 packages/css/tokens.css diff --git a/bun.lock b/bun.lock index 3ea6f31f5d..643a062813 100644 --- a/bun.lock +++ b/bun.lock @@ -413,6 +413,10 @@ "drizzle-kit": "catalog:", }, }, + "packages/css": { + "name": "@opencode-ai/css", + "version": "1.18.4", + }, "packages/desktop": { "name": "@opencode-ai/desktop", "version": "1.18.4", @@ -2085,6 +2089,8 @@ "@opencode-ai/core": ["@opencode-ai/core@workspace:packages/core"], + "@opencode-ai/css": ["@opencode-ai/css@workspace:packages/css"], + "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], "@opencode-ai/docs": ["@opencode-ai/docs@workspace:packages/docs"], diff --git a/packages/css/LICENSE b/packages/css/LICENSE new file mode 100644 index 0000000000..6439474bee --- /dev/null +++ b/packages/css/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 opencode + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/css/README.md b/packages/css/README.md new file mode 100644 index 0000000000..c87eeeec8f --- /dev/null +++ b/packages/css/README.md @@ -0,0 +1,156 @@ +# @opencode-ai/css + +OpenCode's design system as plain CSS. It has no JavaScript, framework, build tool, or runtime dependency. + +Use semantic HTML and `data-*` attributes. Native browser states such as `:hover`, `:focus-visible`, and `:disabled` work automatically; your JavaScript can represent application state with attributes such as `data-expanded`, `data-checked`, and `data-invalid`. + +## Install + +```sh +npm install @opencode-ai/css +``` + +Load the complete design system from JavaScript: + +```js +import "@opencode-ai/css" +``` + +Or from CSS: + +```css +@import "@opencode-ai/css"; +``` + +For a static page with no package tooling: + +```html + +``` + +Add `data-oc-root` to the element that should receive the default page background, text color, and font. Set `data-color-scheme` to `light` or `dark` on that element or an ancestor. + +```html + + + +``` + +## Selective imports + +The default export contains every component. Small pages can load only what they use: + +```css +@import "@opencode-ai/css/tokens.css"; +@import "@opencode-ai/css/base.css"; +@import "@opencode-ai/css/components/button.css"; +@import "@opencode-ai/css/components/badge.css"; +@import "@opencode-ai/css/components/table.css"; +``` + +`tokens.css` defines the color ramps and semantic theme variables. `base.css` is intentionally small and scoped: it only applies the full-page defaults to `[data-oc-root]` and normalization to design-system elements. + +## Common components + +### Button + +```html + + + +``` + +Variants: `neutral`, `contrast`, `outline`, `ghost`, `ghost-muted`, `danger`, `warning`, and `loading`. Sizes: `small`, `normal`, and `large`. + +### Badge + +```html +Inactive Active +``` + +### Internal-tool page + +```html +
+
+
+

Release control

+

Artifacts

+
+
+ +
+
+

Published builds

+

Newest first.

+
+
+
+ + + + + + + + + + + + + + + +
NameStatus
cli@1.0.0Active + +
+
+
+
+
+``` + +### JavaScript-owned state + +The package styles state; it does not implement behavior. Keep semantic HTML and ARIA as the source of truth, then mirror state to a `data-*` attribute when a component needs it. + +```js +const trigger = document.querySelector('[data-component="button"]') +trigger.addEventListener("click", () => { + const expanded = trigger.getAttribute("aria-expanded") !== "true" + trigger.setAttribute("aria-expanded", String(expanded)) + trigger.toggleAttribute("data-expanded", expanded) +}) +``` + +## Theme customization + +Override semantic tokens at your app root instead of editing component CSS: + +```css +[data-oc-root] { + --oc-font-family-sans: Inter, system-ui, sans-serif; + --oc-page-max-width: 1440px; + --oc-bg-accent: #7152f4; + --oc-border-focus: #8271f8; +} +``` + +The low-level color ramps live in `colors.css`; light and dark semantic mappings live in `theme.css`. + +## Component inventory + +The first release is intentionally focused on simple internal tools: + +- Layout: page, surface, and table +- Actions and status: button and badge +- Forms: field, input, select, textarea, and native checkbox/radio styling +- Structure: divider + +Each stylesheet is available as `@opencode-ai/css/components/.css`. This small interface avoids publishing framework-specific DOM contracts for menus, dialogs, comboboxes, and other interactive widgets. + +## Scope + +This package is styling only. It does not create DOM, manage focus, provide keyboard navigation, position overlays, or add ARIA attributes. Use native HTML for simple controls and a small behavior layer of your choice for menus, dialogs, comboboxes, and other interactive widgets. + +`@opencode-ai/ui` does not depend on this package yet. The CSS is intentionally isolated while its public interface settles. diff --git a/packages/css/base.css b/packages/css/base.css new file mode 100644 index 0000000000..b90a82af7b --- /dev/null +++ b/packages/css/base.css @@ -0,0 +1,32 @@ +@layer base { + :root { + --font-weight-regular: 440; + --line-height-normal: 1.5; + --letter-spacing-normal: normal; + --oc-page-max-width: 1200px; + --oc-page-padding-block: 56px; + } + + :where([data-component], [data-component] *, [data-slot]) { + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; + } + + :where([data-component]) { + font-family: var(--oc-font-family-sans); + } +} + +@layer base { + [data-oc-root] { + min-height: 100%; + margin: 0; + background: var(--oc-bg-deep); + color: var(--oc-text-base); + font-family: var(--oc-font-family-sans); + font-size: 13px; + line-height: 1.5; + } +} diff --git a/packages/css/colors.css b/packages/css/colors.css new file mode 100644 index 0000000000..fe616b4945 --- /dev/null +++ b/packages/css/colors.css @@ -0,0 +1,172 @@ +@layer theme { + :root { + /* ── Grey ── */ + --oc-grey-50: #ffffffff; + --oc-grey-100: #fafafaff; + --oc-grey-200: #f2f2f2ff; + --oc-grey-300: #eeeeeeff; + --oc-grey-400: #dbdbdbff; + --oc-grey-500: #aeaeaeff; + --oc-grey-600: #808080ff; + --oc-grey-700: #5c5c5cff; + --oc-grey-800: #3a3a3aff; + --oc-grey-900: #2e2e2eff; + --oc-grey-1000: #242424ff; + --oc-grey-1100: #161616ff; + --oc-grey-1200: #080808ff; + + /* ── Alpha Dark ── */ + --oc-alpha-dark-100: #000000ff; + --oc-alpha-dark-90: #000000e5; + --oc-alpha-dark-80: #000000cc; + --oc-alpha-dark-70: #000000b2; + --oc-alpha-dark-60: #00000099; + --oc-alpha-dark-50: #00000080; + --oc-alpha-dark-40: #00000066; + --oc-alpha-dark-30: #0000004d; + --oc-alpha-dark-24: #0000003d; + --oc-alpha-dark-20: #00000033; + --oc-alpha-dark-16: #00000029; + --oc-alpha-dark-14: #00000024; + --oc-alpha-dark-12: #0000001f; + --oc-alpha-dark-10: #0000001a; + --oc-alpha-dark-8: #00000014; + --oc-alpha-dark-6: #0000000f; + --oc-alpha-dark-4: #0000000a; + --oc-alpha-dark-2: #00000005; + --oc-alpha-dark-0: #00000000; + + /* ── Alpha Light ── */ + --oc-alpha-light-100: #ffffffff; + --oc-alpha-light-90: #ffffffe5; + --oc-alpha-light-80: #ffffffcc; + --oc-alpha-light-70: #ffffffb2; + --oc-alpha-light-60: #ffffff99; + --oc-alpha-light-50: #ffffff80; + --oc-alpha-light-40: #ffffff66; + --oc-alpha-light-30: #ffffff4d; + --oc-alpha-light-24: #ffffff3d; + --oc-alpha-light-20: #ffffff33; + --oc-alpha-light-16: #ffffff29; + --oc-alpha-light-14: #ffffff24; + --oc-alpha-light-12: #ffffff1f; + --oc-alpha-light-10: #ffffff1a; + --oc-alpha-light-8: #ffffff14; + --oc-alpha-light-6: #ffffff0f; + --oc-alpha-light-4: #ffffff0a; + --oc-alpha-light-2: #ffffff05; + --oc-alpha-light-0: #ffffff00; + + /* ── Red ── */ + --oc-red-100: #fcecebff; + --oc-red-200: #f6d5d3ff; + --oc-red-300: #f2bbb7ff; + --oc-red-400: #f29b96ff; + --oc-red-500: #f17471ff; + --oc-red-600: #f1484fff; + --oc-red-700: #d92e3cff; + --oc-red-800: #b82d35ff; + --oc-red-900: #97252bff; + --oc-red-1000: #7a1f23ff; + --oc-red-1100: #5f1a1cff; + --oc-red-1200: #461516ff; + + /* ── Orange ── */ + --oc-orange-100: #fdf2edff; + --oc-orange-200: #ffe7dcff; + --oc-orange-300: #ffd8c6ff; + --oc-orange-400: #ffc1a4ff; + --oc-orange-500: #ffa478ff; + --oc-orange-600: #ff8648ff; + --oc-orange-700: #ee7330ff; + --oc-orange-800: #d16427ff; + --oc-orange-900: #b35624ff; + --oc-orange-1000: #954c27ff; + --oc-orange-1100: #723d22ff; + --oc-orange-1200: #5a2c14ff; + + /* ── Yellow ── */ + --oc-yellow-100: #fefaecff; + --oc-yellow-200: #fcefd0ff; + --oc-yellow-300: #f7e5b5ff; + --oc-yellow-400: #f3da9bff; + --oc-yellow-500: #f2cf76ff; + --oc-yellow-600: #f6c251ff; + --oc-yellow-700: #e7af36ff; + --oc-yellow-800: #cb9f34ff; + --oc-yellow-900: #ac8833ff; + --oc-yellow-1000: #8e7231ff; + --oc-yellow-1100: #68552bff; + --oc-yellow-1200: #4b4025ff; + + /* ── Green ── */ + --oc-green-100: #e7f9eaff; + --oc-green-200: #d0f0d5ff; + --oc-green-300: #b8e9c1ff; + --oc-green-400: #96e3a6ff; + --oc-green-500: #6bd586ff; + --oc-green-600: #49c970ff; + --oc-green-700: #2eaf5aff; + --oc-green-800: #198b43ff; + --oc-green-900: #1d783cff; + --oc-green-1000: #196130ff; + --oc-green-1100: #164c26ff; + --oc-green-1200: #14361dff; + + /* ── Cyan ── */ + --oc-cyan-100: #e2f7fbff; + --oc-cyan-200: #c4edf4ff; + --oc-cyan-300: #a3e4efff; + --oc-cyan-400: #65d9ebff; + --oc-cyan-500: #00c5dfff; + --oc-cyan-600: #00abcfff; + --oc-cyan-700: #0096b8ff; + --oc-cyan-800: #007d9bff; + --oc-cyan-900: #006c85ff; + --oc-cyan-1000: #005a6eff; + --oc-cyan-1100: #004756ff; + --oc-cyan-1200: #00353fff; + + /* ── Blue ── */ + --oc-blue-100: #ecf1feff; + --oc-blue-200: #d7e2fcff; + --oc-blue-300: #c3d4fdff; + --oc-blue-400: #a2bcffff; + --oc-blue-500: #7698fdff; + --oc-blue-600: #3b5cf6ff; + --oc-blue-700: #3250dfff; + --oc-blue-800: #2c47c8ff; + --oc-blue-900: #263fa9ff; + --oc-blue-1000: #22388fff; + --oc-blue-1100: #1c2e70ff; + --oc-blue-1200: #1b2852ff; + + /* ── Purple ── */ + --oc-purple-100: #ebecfeff; + --oc-purple-200: #d5d5fcff; + --oc-purple-300: #b9b8f5ff; + --oc-purple-400: #9e99f7ff; + --oc-purple-500: #8271f8ff; + --oc-purple-600: #7152f4ff; + --oc-purple-700: #623be2ff; + --oc-purple-800: #5230c2ff; + --oc-purple-900: #442aa1ff; + --oc-purple-1000: #361f83ff; + --oc-purple-1100: #2b1b6aff; + --oc-purple-1200: #221358ff; + + /* ── Pink ── */ + --oc-pink-100: #fdecf3ff; + --oc-pink-200: #f7d5e4ff; + --oc-pink-300: #fabcd8ff; + --oc-pink-400: #f799c6ff; + --oc-pink-500: #f26cb2ff; + --oc-pink-600: #f64aabff; + --oc-pink-700: #e4429eff; + --oc-pink-800: #c83d8bff; + --oc-pink-900: #aa3576ff; + --oc-pink-1000: #8c2d61ff; + --oc-pink-1100: #6f284fff; + --oc-pink-1200: #5c1d3fff; + } +} diff --git a/packages/css/components.css b/packages/css/components.css new file mode 100644 index 0000000000..7a643c2002 --- /dev/null +++ b/packages/css/components.css @@ -0,0 +1,9 @@ +@import "./components/badge.css" layer(components); +@import "./components/button.css" layer(components); +@import "./components/check.css" layer(components); +@import "./components/divider.css" layer(components); +@import "./components/field.css" layer(components); +@import "./components/input.css" layer(components); +@import "./components/page.css" layer(components); +@import "./components/surface.css" layer(components); +@import "./components/table.css" layer(components); diff --git a/packages/css/components/badge.css b/packages/css/components/badge.css new file mode 100644 index 0000000000..600f4729d3 --- /dev/null +++ b/packages/css/components/badge.css @@ -0,0 +1,33 @@ +[data-component="badge"] { + box-sizing: border-box; + display: inline-flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 4px; + height: 16px; + padding: 0 4px; + user-select: none; + + border-radius: 2px; + border: 0.5px solid var(--oc-border-base); + background: var(--oc-bg-layer-02); + + font-style: normal; + font-weight: 530; + font-size: 11px; + line-height: 1; + letter-spacing: 0.05px; + color: var(--oc-text-muted); + font-variant-numeric: tabular-nums; +} + +[data-component="badge"][data-high-contrast] { + border-color: var(--oc-border-strong); +} + +[data-component="badge"][data-variant="accent"] { + border: 0; + background: var(--oc-bg-accent); + color: var(--oc-text-contrast); +} diff --git a/packages/css/components/button.css b/packages/css/components/button.css new file mode 100644 index 0000000000..4a629369cd --- /dev/null +++ b/packages/css/components/button.css @@ -0,0 +1,246 @@ +[data-component="button"] { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border-radius: 6px; + font-style: normal; + font-weight: 530; + font-size: 13px; + line-height: 20px; + color: var(--oc-text-base); + text-shadow: none; + font-variant-numeric: tabular-nums; + letter-spacing: -0.04px; + user-select: none; + cursor: pointer; + outline: none; +} + +[data-component="button"]:focus { + outline: none; +} + +[data-component="button"]:is(:focus-visible, [data-state="focus"]):not(:disabled):not([data-variant="loading"]) { + outline: 2px solid var(--oc-border-focus); + outline-offset: 2.5px; +} + +[data-component="button"][data-size="normal"] { + height: 28px; + padding: 0 11px; +} + +[data-component="button"][data-size="small"] { + height: 24px; + padding: 0 9px; + border-radius: 4px; +} + +[data-component="button"][data-size="large"] { + height: 32px; + padding: 0 15px; +} + +[data-component="button"][data-icon][data-size="small"] { + padding-left: 9px; +} + +[data-component="button"][data-icon][data-size="normal"] { + padding-left: 11px; +} + +[data-component="button"][data-icon][data-size="large"] { + padding-left: 15px; +} + +[data-component="button"] [data-slot="icon-svg"] { + color: currentColor; +} + +/* Neutral */ +[data-component="button"][data-variant="neutral"] { + background-color: var(--oc-bg-button-neutral); + color: var(--oc-text-base); + box-shadow: var(--oc-elevation-button-neutral); +} + +[data-component="button"][data-variant="neutral"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--oc-overlay-hover) 0%, var(--oc-overlay-hover) 100%), + linear-gradient(90deg, var(--oc-bg-button-neutral) 0%, var(--oc-bg-button-neutral) 100%); +} + +[data-component="button"][data-variant="neutral"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--oc-overlay-pressed) 0%, var(--oc-overlay-pressed) 100%), + linear-gradient(90deg, var(--oc-bg-button-neutral) 0%, var(--oc-bg-button-neutral) 100%); +} + +[data-component="button"][data-variant="neutral"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.5; + cursor: not-allowed; +} + +/* Danger */ +[data-component="button"][data-variant="danger"] { + background-color: var(--oc-bg-button-neutral); + color: var(--oc-state-fg-danger); + box-shadow: var(--oc-elevation-button-neutral); +} + +[data-component="button"][data-variant="danger"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--oc-overlay-hover) 0%, var(--oc-overlay-hover) 100%), + linear-gradient(90deg, var(--oc-bg-button-neutral) 0%, var(--oc-bg-button-neutral) 100%); +} + +[data-component="button"][data-variant="danger"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--oc-overlay-pressed) 0%, var(--oc-overlay-pressed) 100%), + linear-gradient(90deg, var(--oc-bg-button-neutral) 0%, var(--oc-bg-button-neutral) 100%); +} + +[data-component="button"][data-variant="danger"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.5; + cursor: not-allowed; +} + +[data-component="button"][data-variant="warning"] { + background-color: var(--oc-bg-button-neutral); + color: var(--oc-state-fg-warning); + box-shadow: var(--oc-elevation-button-neutral); +} + +[data-component="button"][data-variant="warning"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--oc-overlay-hover) 0%, var(--oc-overlay-hover) 100%), + linear-gradient(90deg, var(--oc-bg-button-neutral) 0%, var(--oc-bg-button-neutral) 100%); +} + +[data-component="button"][data-variant="warning"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--oc-overlay-pressed) 0%, var(--oc-overlay-pressed) 100%), + linear-gradient(90deg, var(--oc-bg-button-neutral) 0%, var(--oc-bg-button-neutral) 100%); +} + +[data-component="button"][data-variant="warning"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.5; + cursor: not-allowed; +} + +/* Outline */ +[data-component="button"][data-variant="outline"] { + background-color: transparent; + color: var(--oc-text-base); + box-shadow: inset 0 0 0 1px var(--oc-border-muted); +} + +[data-component="button"][data-variant="outline"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-color: var(--oc-overlay-hover); +} + +[data-component="button"][data-variant="outline"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-color: var(--oc-overlay-pressed); +} + +[data-component="button"][data-variant="outline"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.5; + cursor: not-allowed; +} + +/* Contrast */ +[data-component="button"][data-variant="contrast"] { + background-image: + linear-gradient(180deg, var(--oc-alpha-light-20) 0%, var(--oc-alpha-light-0) 100%), + linear-gradient(90deg, var(--oc-bg-contrast) 0%, var(--oc-bg-contrast) 100%); + color: var(--oc-text-contrast); + text-shadow: 0 0.5px 0.5px rgba(0, 0, 0, 0.3); + box-shadow: var(--oc-elevation-button-contrast); +} + +[data-component="button"][data-variant="contrast"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--oc-overlay-contrast-hover) 0%, var(--oc-overlay-contrast-hover) 100%), + linear-gradient(180deg, var(--oc-alpha-light-20) 0%, var(--oc-alpha-light-0) 100%), + linear-gradient(90deg, var(--oc-bg-contrast) 0%, var(--oc-bg-contrast) 100%); +} + +[data-component="button"][data-variant="contrast"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--oc-overlay-contrast-pressed) 0%, var(--oc-overlay-contrast-pressed) 100%), + linear-gradient(180deg, var(--oc-alpha-light-20) 0%, var(--oc-alpha-light-0) 100%), + linear-gradient(90deg, var(--oc-bg-contrast) 0%, var(--oc-bg-contrast) 100%); +} + +[data-component="button"][data-variant="contrast"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.4; + cursor: not-allowed; +} + +/* Loading */ +[data-component="button"][data-variant="loading"] { + background: #f2f2f2; + color: var(--oc-text-base); + box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.08); + cursor: default; + pointer-events: none; +} + +/* Ghost */ +[data-component="button"][data-variant="ghost"] { + background-color: transparent; + color: var(--oc-text-base); +} + +[data-component="button"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled):not([data-expanded]) { + background-color: var(--oc-overlay-hover); +} + +[data-component="button"][data-variant="ghost"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-color: var(--oc-overlay-pressed); +} + +[data-component="button"][data-variant="ghost"]:where([data-expanded]):not(:disabled) { + background-color: var(--oc-overlay-pressed); +} + +[data-component="button"][data-variant="ghost"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.5; + cursor: not-allowed; +} + +/* Ghost muted */ +[data-component="button"][data-variant="ghost-muted"] { + background-color: transparent; + color: var(--oc-text-muted); +} + +[data-component="button"][data-variant="ghost-muted"] [data-slot="icon-svg"] { + color: var(--oc-icon-muted); +} + +[data-component="button"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not( + [data-expanded] + ) { + background-color: var(--oc-overlay-hover); +} + +[data-component="button"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-color: var(--oc-overlay-pressed); +} + +[data-component="button"][data-variant="ghost-muted"]:where([data-expanded]):not(:disabled) { + background-color: var(--oc-overlay-pressed); + color: var(--oc-text-base); +} + +[data-component="button"][data-variant="ghost-muted"]:where([data-expanded]):not(:disabled) [data-slot="icon-svg"] { + color: var(--oc-icon-base); +} + +[data-component="button"][data-variant="ghost-muted"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/packages/css/components/check.css b/packages/css/components/check.css new file mode 100644 index 0000000000..836f99f90f --- /dev/null +++ b/packages/css/components/check.css @@ -0,0 +1,20 @@ +[data-component="check"] { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--oc-text-base); + font-size: 13px; + user-select: none; +} + +[data-component="check"] :is(input[type="checkbox"], input[type="radio"]) { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--oc-bg-accent); +} + +[data-component="check"]:has(input:disabled) { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/packages/css/components/divider.css b/packages/css/components/divider.css new file mode 100644 index 0000000000..d64746cce5 --- /dev/null +++ b/packages/css/components/divider.css @@ -0,0 +1,11 @@ +[data-component="divider"] { + box-sizing: border-box; + width: 100%; + height: 1px; + transform: scaleY(0.5); + border: none; + background: var(--oc-border-strong); + flex: none; + align-self: stretch; + flex-grow: 0; +} diff --git a/packages/css/components/field.css b/packages/css/components/field.css new file mode 100644 index 0000000000..366f3ebe55 --- /dev/null +++ b/packages/css/components/field.css @@ -0,0 +1,24 @@ +[data-component="field"] { + display: grid; + gap: 6px; + min-width: 0; +} + +[data-component="field"] [data-slot="field-label"] { + color: var(--oc-text-base); + font-size: 13px; + font-weight: 530; + line-height: 1.25; +} + +[data-component="field"] [data-slot="field-description"] { + margin: 0; + color: var(--oc-text-muted); + font-size: 11px; +} + +[data-component="field"] [data-slot="field-error"] { + margin: 0; + color: var(--oc-state-fg-danger); + font-size: 11px; +} diff --git a/packages/css/components/input.css b/packages/css/components/input.css new file mode 100644 index 0000000000..e453289ffe --- /dev/null +++ b/packages/css/components/input.css @@ -0,0 +1,55 @@ +[data-component="input"], +[data-component="select"], +[data-component="textarea"] { + width: 100%; + min-width: 0; + border: 0; + border-radius: 6px; + outline: 1px solid transparent; + background: linear-gradient(180deg, var(--oc-alpha-light-2) 0%, var(--oc-alpha-light-0) 100%), var(--oc-bg-base); + box-shadow: var(--oc-elevation-button-neutral); + color: var(--oc-text-base); + font: inherit; + font-size: 13px; + transition: + outline-color 85ms ease-out, + background 85ms ease-out; +} + +[data-component="input"], +[data-component="select"] { + height: 32px; + padding: 0 10px; +} + +[data-component="textarea"] { + min-height: 88px; + padding: 8px 10px; + line-height: 1.5; + resize: vertical; +} + +:is([data-component="input"], [data-component="select"], [data-component="textarea"])::placeholder { + color: var(--oc-text-faint); +} + +:is([data-component="input"], [data-component="select"], [data-component="textarea"]):hover:not(:disabled) { + background: linear-gradient(0deg, var(--oc-overlay-hover), var(--oc-overlay-hover)), var(--oc-bg-base); +} + +:is([data-component="input"], [data-component="select"], [data-component="textarea"]):focus-visible { + outline: 2px solid var(--oc-border-focus); + outline-offset: 1px; +} + +:is([data-component="input"], [data-component="select"], [data-component="textarea"]):is(:disabled, [data-disabled]) { + opacity: 0.5; + cursor: not-allowed; +} + +:is([data-component="input"], [data-component="select"], [data-component="textarea"]):is( + [aria-invalid="true"], + [data-invalid] +) { + outline-color: var(--oc-state-fg-danger); +} diff --git a/packages/css/components/page.css b/packages/css/components/page.css new file mode 100644 index 0000000000..4dc2984373 --- /dev/null +++ b/packages/css/components/page.css @@ -0,0 +1,64 @@ +[data-component="page"] { + width: min(100% - 32px, var(--oc-page-max-width, 1200px)); + margin-inline: auto; + padding-block: var(--oc-page-padding-block, 56px); +} + +[data-component="page"] [data-slot="page-header"] { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 32px; +} + +[data-component="page"] [data-slot="page-heading"] { + min-width: 0; +} + +[data-component="page"] [data-slot="page-eyebrow"] { + margin: 0 0 4px; + color: var(--oc-text-muted); + font-size: 13px; +} + +[data-component="page"] [data-slot="page-title"] { + margin: 0; + color: var(--oc-text-base); + font-size: clamp(36px, 6vw, 64px); + font-weight: 440; + line-height: 0.95; + letter-spacing: -0.045em; +} + +[data-component="page"] [data-slot="page-description"] { + max-width: 64ch; + margin: 12px 0 0; + color: var(--oc-text-muted); +} + +[data-component="page"] [data-slot="page-actions"] { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex: none; +} + +[data-component="page"] [data-slot="page-content"] { + display: grid; + gap: 16px; +} + +@media (max-width: 640px) { + [data-component="page"] { + width: min(100% - 24px, var(--oc-page-max-width, 1200px)); + padding-block: 32px; + } + + [data-component="page"] [data-slot="page-header"] { + align-items: flex-start; + flex-direction: column; + margin-bottom: 24px; + } +} diff --git a/packages/css/components/surface.css b/packages/css/components/surface.css new file mode 100644 index 0000000000..1f443ad526 --- /dev/null +++ b/packages/css/components/surface.css @@ -0,0 +1,38 @@ +[data-component="surface"] { + overflow: hidden; + border: 1px solid var(--oc-border-muted); + border-radius: 12px; + background: var(--oc-bg-layer-01); + box-shadow: var(--oc-elevation-raised); +} + +[data-component="surface"][data-variant="flat"] { + box-shadow: none; +} + +[data-component="surface"] [data-slot="surface-header"] { + padding: 24px 24px 16px; +} + +[data-component="surface"] [data-slot="surface-title"] { + margin: 0; + color: var(--oc-text-base); + font-size: 16px; + font-weight: 530; + line-height: 1.25; + letter-spacing: -0.01em; +} + +[data-component="surface"] [data-slot="surface-description"] { + margin: 6px 0 0; + color: var(--oc-text-muted); + font-size: 13px; +} + +[data-component="surface"] [data-slot="surface-content"] { + padding: 0 24px 24px; +} + +[data-component="surface"][data-padding="none"] [data-slot="surface-content"] { + padding: 0; +} diff --git a/packages/css/components/table.css b/packages/css/components/table.css new file mode 100644 index 0000000000..b8031611c3 --- /dev/null +++ b/packages/css/components/table.css @@ -0,0 +1,52 @@ +[data-component="table-container"] { + width: 100%; + overflow-x: auto; +} + +[data-component="table"] { + width: 100%; + border-collapse: collapse; + color: var(--oc-text-base); + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +[data-component="table"] :is(th, td) { + height: 48px; + padding: 0 16px; + border-bottom: 1px solid var(--oc-border-muted); + text-align: left; + vertical-align: middle; + white-space: nowrap; +} + +[data-component="table"] th { + height: 40px; + color: var(--oc-text-muted); + font-size: 11px; + font-weight: 530; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +[data-component="table"] tbody tr:last-child td { + border-bottom: 0; +} + +[data-component="table"] tbody tr:is(:hover, [data-state="hover"]) td { + background: var(--oc-overlay-hover); +} + +[data-component="table"] [data-align="end"] { + text-align: right; +} + +[data-component="table"] [data-numeric] { + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; +} + +[data-component="table"] [data-slot="table-actions"] { + display: flex; + justify-content: flex-end; + gap: 8px; +} diff --git a/packages/css/examples/internal-tool.html b/packages/css/examples/internal-tool.html new file mode 100644 index 0000000000..974b3e034d --- /dev/null +++ b/packages/css/examples/internal-tool.html @@ -0,0 +1,99 @@ + + + + + + + OpenCode CSS internal tool + + + + +
+
+
+

Release control

+

Artifacts

+
+ dax@anoma.ly +
+ +
+
+

Published builds

+

+ Every build received from the trusted publishing workflow, newest first. +

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChannelNameDistributionVersionCreatedStatus
nextclinpm0.0.0-next-160202026-07-22T13:23:42.642ZActive
nextclinpm0.0.0-next-160192026-07-22T10:33:24.519ZInactive + +
nextclinpm0.0.0-next-160182026-07-22T10:03:19.411ZInactive + +
nextclinpm0.0.0-next-160172026-07-22T09:28:50.782ZInactive + +
+
+
+
+
+ + diff --git a/packages/css/index.css b/packages/css/index.css new file mode 100644 index 0000000000..3a759bd79d --- /dev/null +++ b/packages/css/index.css @@ -0,0 +1,3 @@ +@import "./tokens.css"; +@import "./base.css"; +@import "./components.css"; diff --git a/packages/css/package.json b/packages/css/package.json new file mode 100644 index 0000000000..b93b1ff27f --- /dev/null +++ b/packages/css/package.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/css", + "version": "1.18.4", + "description": "OpenCode's framework-agnostic CSS design system", + "type": "module", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/anomalyco/opencode.git", + "directory": "packages/css" + }, + "homepage": "https://github.com/anomalyco/opencode/tree/v2/packages/css", + "bugs": "https://github.com/anomalyco/opencode/issues", + "keywords": [ + "css", + "design-system", + "opencode" + ], + "publishConfig": { + "access": "public" + }, + "style": "./index.css", + "sideEffects": [ + "**/*.css" + ], + "files": [ + "*.css", + "components", + "README.md", + "LICENSE" + ], + "exports": { + ".": "./index.css", + "./index.css": "./index.css", + "./base.css": "./base.css", + "./tokens.css": "./tokens.css", + "./components.css": "./components.css", + "./components/*.css": "./components/*.css", + "./package.json": "./package.json" + }, + "scripts": { + "test": "bun test" + } +} diff --git a/packages/css/script/publish.ts b/packages/css/script/publish.ts new file mode 100644 index 0000000000..e405780363 --- /dev/null +++ b/packages/css/script/publish.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env bun + +import { Script } from "@opencode-ai/script" +import { $ } from "bun" +import { rm } from "node:fs/promises" +import { fileURLToPath } from "node:url" + +process.chdir(fileURLToPath(new URL("..", import.meta.url))) + +const pkg = (await Bun.file("package.json").json()) as { name: string; version: string } +const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz` + +if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) { + console.log(`already published ${pkg.name}@${pkg.version}`) + process.exit(0) +} + +try { + await $`bun run test` + await $`bun pm pack` + await $`npm publish ${tarball} --access public --tag ${Script.channel}` +} finally { + await rm(tarball, { force: true }) +} diff --git a/packages/css/test/package.test.ts b/packages/css/test/package.test.ts new file mode 100644 index 0000000000..826c9c58a5 --- /dev/null +++ b/packages/css/test/package.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" + +const root = path.join(import.meta.dir, "..") +const manifest = await Bun.file(path.join(root, "package.json")).json() + +function imports(file: string) { + return [...file.matchAll(/@import\s+["'](.+?)["']/g)].map((match) => match[1]) +} + +describe("package", () => { + test("all exports resolve to files", async () => { + const targets = Object.values(manifest.exports) as string[] + const concrete = targets.filter((target) => !target.includes("*")) + expect(await Promise.all(concrete.map((target) => Bun.file(path.join(root, target)).exists()))).toEqual( + concrete.map(() => true), + ) + }) + + test("all local CSS imports resolve", async () => { + const files = [...new Bun.Glob("**/*.css").scanSync({ cwd: root, absolute: true })] + const missing = ( + await Promise.all( + files.map(async (file) => + Promise.all( + imports(await Bun.file(file).text()) + .filter((target) => target.startsWith(".")) + .map(async (target) => + (await Bun.file(path.resolve(path.dirname(file), target)).exists()) ? undefined : target, + ), + ), + ), + ) + ) + .flat() + .filter(Boolean) + expect(missing).toEqual([]) + }) + + test("component index includes every component once", async () => { + const expected = [...new Bun.Glob("components/*.css").scanSync({ cwd: root })].sort() + const actual = imports(await Bun.file(path.join(root, "components.css")).text()) + .map((target) => target.replace(/^\.\//, "")) + .sort() + expect(actual).toEqual(expected) + }) + + test("public CSS does not expose v2 names", async () => { + const files = [...new Bun.Glob("**/*.css").scanSync({ cwd: root, absolute: true })] + const stale = ( + await Promise.all(files.map(async (file) => ((await Bun.file(file).text()).includes("-v2") ? file : undefined))) + ).filter(Boolean) + expect(stale).toEqual([]) + }) + + test("all referenced tokens are declared", async () => { + const files = [...new Bun.Glob("**/*.css").scanSync({ cwd: root, absolute: true })] + const css = (await Promise.all(files.map((file) => Bun.file(file).text()))).join("\n") + const declared = new Set([...css.matchAll(/(--[\w-]+)\s*:/g)].map((match) => match[1])) + const referenced = new Set([...css.matchAll(/var\((--[\w-]+)/g)].map((match) => match[1])) + expect([...referenced].filter((token) => !declared.has(token)).sort()).toEqual([]) + }) +}) diff --git a/packages/css/theme.css b/packages/css/theme.css new file mode 100644 index 0000000000..e08fdde171 --- /dev/null +++ b/packages/css/theme.css @@ -0,0 +1,107 @@ +@layer theme { + :root { + --oc-font-family-sans: "Inter", ui-sans-serif, system-ui, sans-serif; + } + + :root, + [data-color-scheme="light"] { + color-scheme: light; + + --oc-bg-base: var(--oc-grey-50); + --oc-bg-deep: var(--oc-grey-100); + --oc-bg-layer-01: var(--oc-grey-100); + --oc-bg-layer-02: var(--oc-grey-200); + --oc-bg-layer-03: var(--oc-grey-300); + --oc-bg-contrast: var(--oc-grey-1000); + --oc-bg-button-neutral: var(--oc-grey-50); + --oc-bg-accent: var(--oc-blue-600); + + --oc-text-base: var(--oc-grey-1100); + --oc-text-muted: var(--oc-grey-700); + --oc-text-faint: var(--oc-grey-600); + --oc-text-contrast: var(--oc-grey-50); + --oc-icon-base: var(--oc-grey-800); + --oc-icon-muted: var(--oc-grey-600); + + --oc-border-muted: var(--oc-alpha-dark-8); + --oc-border-base: var(--oc-alpha-dark-10); + --oc-border-strong: var(--oc-alpha-dark-20); + --oc-border-focus: var(--oc-blue-500); + + --oc-overlay-hover: var(--oc-alpha-dark-4); + --oc-overlay-pressed: var(--oc-alpha-dark-8); + --oc-overlay-contrast-hover: var(--oc-alpha-light-12); + --oc-overlay-contrast-pressed: var(--oc-alpha-light-24); + + --oc-state-bg-success: var(--oc-green-100); + --oc-state-fg-success: var(--oc-green-800); + --oc-state-border-success: var(--oc-green-300); + --oc-state-bg-warning: var(--oc-yellow-100); + --oc-state-fg-warning: var(--oc-yellow-800); + --oc-state-border-warning: var(--oc-yellow-300); + --oc-state-bg-danger: var(--oc-red-100); + --oc-state-fg-danger: var(--oc-red-800); + --oc-state-border-danger: var(--oc-red-300); + --oc-state-bg-info: var(--oc-blue-100); + --oc-state-fg-info: var(--oc-blue-800); + --oc-state-border-info: var(--oc-blue-300); + + --oc-elevation-raised: + 0 2px 4px var(--oc-alpha-dark-4), 0 1px 2px -1px var(--oc-alpha-dark-8), 0 0 0 0.5px var(--oc-alpha-dark-12); + --oc-elevation-button-neutral: 0 1px 1.5px var(--oc-alpha-dark-10), 0 0 0 0.5px var(--oc-alpha-dark-14); + --oc-elevation-button-contrast: + 0 1px 1.5px var(--oc-alpha-dark-20), 0 0 0 0.5px var(--oc-grey-800), inset 0 1px 2px var(--oc-alpha-light-14), + inset 0 -1px 2px var(--oc-alpha-dark-6); + } + + [data-color-scheme="dark"] { + color-scheme: dark; + + --oc-bg-base: var(--oc-grey-1100); + --oc-bg-deep: var(--oc-grey-1200); + --oc-bg-layer-01: var(--oc-grey-1000); + --oc-bg-layer-02: var(--oc-grey-900); + --oc-bg-layer-03: var(--oc-grey-800); + --oc-bg-contrast: var(--oc-grey-700); + --oc-bg-button-neutral: var(--oc-alpha-light-6); + --oc-bg-accent: var(--oc-blue-600); + + --oc-text-base: var(--oc-grey-100); + --oc-text-muted: var(--oc-grey-500); + --oc-text-faint: var(--oc-grey-600); + --oc-text-contrast: var(--oc-grey-50); + --oc-icon-base: var(--oc-grey-400); + --oc-icon-muted: var(--oc-grey-600); + + --oc-border-muted: var(--oc-alpha-light-8); + --oc-border-base: var(--oc-alpha-light-10); + --oc-border-strong: var(--oc-alpha-light-20); + --oc-border-focus: var(--oc-blue-500); + + --oc-overlay-hover: var(--oc-alpha-light-6); + --oc-overlay-pressed: var(--oc-alpha-light-10); + --oc-overlay-contrast-hover: var(--oc-alpha-dark-24); + --oc-overlay-contrast-pressed: var(--oc-alpha-dark-40); + + --oc-state-bg-success: var(--oc-green-1200); + --oc-state-fg-success: var(--oc-green-500); + --oc-state-border-success: var(--oc-green-900); + --oc-state-bg-warning: var(--oc-yellow-1200); + --oc-state-fg-warning: var(--oc-yellow-500); + --oc-state-border-warning: var(--oc-yellow-900); + --oc-state-bg-danger: var(--oc-red-1200); + --oc-state-fg-danger: var(--oc-red-500); + --oc-state-border-danger: var(--oc-red-900); + --oc-state-bg-info: var(--oc-blue-1200); + --oc-state-fg-info: var(--oc-blue-500); + --oc-state-border-info: var(--oc-blue-900); + + --oc-elevation-raised: + 0 2px 4px var(--oc-alpha-dark-30), 0 1px 2px var(--oc-alpha-dark-30), 0 0 0 0.5px var(--oc-alpha-light-16), + 0 -0.5px 0 var(--oc-alpha-light-6); + --oc-elevation-button-neutral: + 0 1px 2px var(--oc-alpha-dark-40), 0 0 0 0.5px var(--oc-alpha-light-20), 0 -0.5px 0 var(--oc-alpha-light-10); + --oc-elevation-button-contrast: + 0 1px 2px var(--oc-alpha-dark-40), 0 0 0 0.5px var(--oc-alpha-light-40), 0 -0.5px 0 var(--oc-alpha-light-30); + } +} diff --git a/packages/css/tokens.css b/packages/css/tokens.css new file mode 100644 index 0000000000..7c6ef245b4 --- /dev/null +++ b/packages/css/tokens.css @@ -0,0 +1,2 @@ +@import "./colors.css"; +@import "./theme.css"; diff --git a/script/publish.ts b/script/publish.ts index 3d74fd6812..09a2d6a41d 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -63,6 +63,9 @@ await $`bun ./packages/plugin/script/publish.ts` console.log("\n=== ui ===\n") await $`bun ./packages/ui/script/publish.ts` +console.log("\n=== css ===\n") +await $`bun ./packages/css/script/publish.ts` + if (Script.release) { await $`bun ./packages/desktop/scripts/finalize-latest-json.ts` await $`bun ./packages/desktop/scripts/finalize-latest-yml.ts`