# Soft Club UI (full reference) > Open-source React component library with a late-90s Gen X Soft Club aesthetic: dark glass surfaces, phosphor green, cold blue, film-graded gradients, and CRT-era texture. 118 components, four switchable themes, Radix UI primitives underneath. MIT licensed. Soft Club UI ships two ways. As npm packages: `@softclub/ui` (components) plus `@softclub/tokens` (CSS design tokens). And as a shadcn-compatible registry, where any single component can be copied into a project as owned source code. This is the expanded companion to [llms.txt](https://cobanov.github.io/soft-club-ui/llms.txt). It inlines the complete design token sheet, the complete component stylesheet, and the full TypeScript source of every component, hook, and utility, exactly as the shadcn registry distributes them. Nothing else is required to understand or use the library. Facts that matter when generating code with this library: - Install the packages: `pnpm add @softclub/ui @softclub/tokens`, then `import "@softclub/ui/styles.css"` once at the app entry. Components are named exports: `import { Button, Sheet } from "@softclub/ui"`. - Or copy one component with the official shadcn CLI: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/.json`. The CLI also installs the component's npm dependencies, sibling components, hooks, and the base stylesheet. - Themes are selected with the `data-sc-theme` attribute on ``: `green` (default), `blue`, `orange`, or `night-city`. - Every visual value is a CSS custom property prefixed `--sc-` (color, spacing, radius, border, shadow, motion). Theme-aware alpha composition uses RGB triples, for example `rgb(var(--sc-theme-rgb-primary) / 0.2)`. - Components forward refs, accept `className`, and use `sc-`-prefixed class names. Interactive primitives (Dialog, Tabs, Popover, DropdownMenu, Toast, Sheet, Menubar, ContextMenu, NavigationMenu) wrap Radix UI, so open state, focus, and keyboard semantics follow Radix conventions. - The sources below are the registry variants: imports use the `@/` alias convention (`@/lib/soft-club/cx`, `@/components/soft-club/`) and carry a `"use client"` directive. The npm package ships the same components with relative imports. ## Using the npm package ```tsx import "@softclub/ui/styles.css"; import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@softclub/ui"; export function Example() { return ( Room A3 Glass ); } ``` Select a theme on the root element: ``. ## Design tokens (@softclub/tokens) Every `--sc-*` custom property and all four theme blocks. The registry `base` item installs this file to `styles/soft-club-tokens.css`. ```css :root { color-scheme: dark; --sc-theme-rgb-primary: 142 255 173; --sc-theme-rgb-secondary: 139 184 215; --sc-theme-rgb-warm: 255 138 61; --sc-theme-rgb-panel: 225 255 237; --sc-theme-rgb-text: 247 255 248; --sc-color-background: #050706; --sc-color-background-soft: #0a1110; --sc-color-surface: rgba(13, 22, 19, 0.74); --sc-color-surface-raised: rgba(25, 42, 36, 0.78); --sc-color-surface-muted: rgba(61, 88, 73, 0.42); --sc-color-surface-inset: rgba(4, 9, 8, 0.72); --sc-color-glass: rgba(161, 255, 204, 0.12); --sc-color-text: #eef6ee; --sc-color-text-muted: #a9b9ae; --sc-color-text-subtle: #74867b; --sc-color-border: rgba(174, 255, 210, 0.24); --sc-color-border-strong: rgba(190, 255, 222, 0.46); --sc-color-accent-blue: #8bb8d7; --sc-color-accent-blue-soft: rgba(139, 184, 215, 0.18); --sc-color-accent-green: #8effad; --sc-color-accent-green-soft: rgba(142, 255, 173, 0.18); --sc-color-danger: #f06a54; --sc-color-danger-soft: rgba(240, 106, 84, 0.18); --sc-color-warning: #ff8a3d; --sc-color-warning-soft: rgba(255, 138, 61, 0.18); --sc-color-focus: rgba(142, 255, 173, 0.44); --sc-color-overlay: rgba(0, 4, 3, 0.74); --sc-font-sans: "Space Grotesk", "Helvetica Neue", Helvetica, Arial, sans-serif; --sc-font-letter-spacing: 0; --sc-font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace; --sc-font-size-1: 0.6875rem; --sc-font-size-2: 0.75rem; --sc-font-size-3: 0.875rem; --sc-font-size-4: 1rem; --sc-font-size-5: 1.1875rem; --sc-font-size-6: 1.625rem; --sc-line-height-tight: 1.12; --sc-line-height-normal: 1.45; --sc-line-height-loose: 1.62; --sc-font-weight-regular: 400; --sc-font-weight-medium: 500; --sc-font-weight-bold: 700; --sc-space-1: 0.25rem; --sc-space-2: 0.5rem; --sc-space-3: 0.75rem; --sc-space-4: 1rem; --sc-space-5: 1.25rem; --sc-space-6: 1.5rem; --sc-space-8: 2rem; --sc-space-10: 2.5rem; --sc-radius-1: 0.125rem; --sc-radius-2: 0.25rem; --sc-radius-3: 0.375rem; --sc-radius-round: 999px; --sc-border-width: 1px; --sc-border: var(--sc-border-width) solid var(--sc-color-border); --sc-border-strong: var(--sc-border-width) solid var(--sc-color-border-strong); --sc-shadow-1: 0 1px 0 rgba(255, 255, 255, 0.06), 0 8px 24px rgba(0, 0, 0, 0.22); --sc-shadow-2: 0 1px 0 rgba(255, 255, 255, 0.07), 0 18px 42px rgba(0, 0, 0, 0.34); --sc-shadow-inset: inset 0 1px 0 rgba(218, 255, 232, 0.12), inset 0 -1px 0 rgba(0, 0, 0, 0.34); --sc-motion-duration-fast: 120ms; --sc-motion-duration-normal: 180ms; --sc-motion-ease: cubic-bezier(0.2, 0, 0, 1); } :root, [data-sc-theme="green"] { --sc-theme-rgb-primary: 142 255 173; --sc-theme-rgb-secondary: 139 184 215; --sc-theme-rgb-warm: 255 138 61; --sc-theme-rgb-panel: 225 255 237; --sc-theme-rgb-text: 247 255 248; --sc-color-background: #050706; --sc-color-background-soft: #0a1110; --sc-color-surface: rgba(13, 22, 19, 0.74); --sc-color-surface-raised: rgba(25, 42, 36, 0.78); --sc-color-surface-muted: rgba(61, 88, 73, 0.42); --sc-color-surface-inset: rgba(4, 9, 8, 0.72); --sc-color-glass: rgba(161, 255, 204, 0.12); --sc-color-text: #eef6ee; --sc-color-text-muted: #a9b9ae; --sc-color-text-subtle: #74867b; --sc-color-border: rgba(174, 255, 210, 0.24); --sc-color-border-strong: rgba(190, 255, 222, 0.46); --sc-color-accent-blue: #8bb8d7; --sc-color-accent-blue-soft: rgba(139, 184, 215, 0.18); --sc-color-accent-green: #8effad; --sc-color-accent-green-soft: rgba(142, 255, 173, 0.18); --sc-color-danger: #f06a54; --sc-color-danger-soft: rgba(240, 106, 84, 0.18); --sc-color-warning: #ff8a3d; --sc-color-warning-soft: rgba(255, 138, 61, 0.18); --sc-color-focus: rgba(142, 255, 173, 0.44); --sc-color-overlay: rgba(0, 4, 3, 0.74); } [data-sc-theme="blue"] { --sc-theme-rgb-primary: 135 242 230; --sc-theme-rgb-secondary: 126 188 255; --sc-theme-rgb-warm: 255 209 106; --sc-theme-rgb-panel: 232 247 255; --sc-theme-rgb-text: 240 249 255; --sc-color-background: #04070d; --sc-color-background-soft: #07111d; --sc-color-surface: rgba(11, 23, 40, 0.78); --sc-color-surface-raised: rgba(22, 45, 70, 0.78); --sc-color-surface-muted: rgba(73, 113, 153, 0.38); --sc-color-surface-inset: rgba(3, 8, 14, 0.76); --sc-color-glass: rgba(142, 204, 255, 0.14); --sc-color-text: #eef8ff; --sc-color-text-muted: #a7bed2; --sc-color-text-subtle: #718aa0; --sc-color-border: rgba(139, 198, 255, 0.26); --sc-color-border-strong: rgba(188, 225, 255, 0.48); --sc-color-accent-blue: #7ebcff; --sc-color-accent-blue-soft: rgba(126, 188, 255, 0.18); --sc-color-accent-green: #87f2e6; --sc-color-accent-green-soft: rgba(135, 242, 230, 0.18); --sc-color-danger: #ff756c; --sc-color-danger-soft: rgba(255, 117, 108, 0.18); --sc-color-warning: #ffd16a; --sc-color-warning-soft: rgba(255, 209, 106, 0.18); --sc-color-focus: rgba(135, 242, 230, 0.44); --sc-color-overlay: rgba(0, 4, 10, 0.78); } [data-sc-theme="orange"] { --sc-theme-rgb-primary: 255 154 32; --sc-theme-rgb-secondary: 255 210 122; --sc-theme-rgb-warm: 255 207 53; --sc-theme-rgb-panel: 255 238 212; --sc-theme-rgb-text: 255 246 232; --sc-color-background: #0b0300; --sc-color-background-soft: #170704; --sc-color-surface: rgba(35, 12, 4, 0.78); --sc-color-surface-raised: rgba(66, 24, 5, 0.78); --sc-color-surface-muted: rgba(122, 52, 10, 0.42); --sc-color-surface-inset: rgba(14, 5, 2, 0.76); --sc-color-glass: rgba(255, 138, 31, 0.14); --sc-color-text: #fff4e7; --sc-color-text-muted: #d7b393; --sc-color-text-subtle: #a2714b; --sc-color-border: rgba(255, 145, 35, 0.28); --sc-color-border-strong: rgba(255, 190, 95, 0.5); --sc-color-accent-blue: #ffd27a; --sc-color-accent-blue-soft: rgba(255, 210, 122, 0.18); --sc-color-accent-green: #ff9a20; --sc-color-accent-green-soft: rgba(255, 154, 32, 0.18); --sc-color-danger: #ff6b35; --sc-color-danger-soft: rgba(255, 107, 53, 0.18); --sc-color-warning: #ffcf35; --sc-color-warning-soft: rgba(255, 207, 53, 0.2); --sc-color-focus: rgba(255, 154, 32, 0.46); --sc-color-overlay: rgba(9, 2, 0, 0.8); } /* Night City: petrol-blue glass, neon cyan lights, hot HUD red. */ [data-sc-theme="night-city"] { --sc-theme-rgb-primary: 161 234 227; --sc-theme-rgb-secondary: 94 179 175; --sc-theme-rgb-warm: 210 35 72; --sc-theme-rgb-panel: 209 240 236; --sc-theme-rgb-text: 234 246 244; --sc-color-background: #171019; --sc-color-background-soft: #1c1422; --sc-color-surface: rgba(21, 49, 63, 0.72); --sc-color-surface-raised: rgba(30, 64, 80, 0.8); --sc-color-surface-muted: rgba(50, 104, 112, 0.42); --sc-color-surface-inset: rgba(9, 6, 13, 0.74); --sc-color-glass: rgba(161, 234, 227, 0.12); --sc-color-text: #eaf6f4; --sc-color-text-muted: #9fb4b4; --sc-color-text-subtle: #b09194; --sc-color-border: rgba(161, 234, 227, 0.22); --sc-color-border-strong: rgba(161, 234, 227, 0.46); --sc-color-accent-blue: #5eb3af; --sc-color-accent-blue-soft: rgba(94, 179, 175, 0.18); --sc-color-accent-green: #a1eae3; --sc-color-accent-green-soft: rgba(161, 234, 227, 0.18); --sc-color-danger: #d22348; --sc-color-danger-soft: rgba(210, 35, 72, 0.2); --sc-color-warning: #ff6b8a; --sc-color-warning-soft: rgba(255, 107, 138, 0.2); --sc-color-focus: rgba(161, 234, 227, 0.46); --sc-color-overlay: rgba(8, 5, 12, 0.8); } ``` ## Component stylesheet (@softclub/ui) All `sc-*` component class names are defined here. The registry `base` item installs this file to `styles/soft-club.css`. ```css @import "./soft-club-tokens.css"; @font-face { font-display: swap; font-family: "Space Grotesk"; font-style: normal; font-weight: 400; src: url("https://fonts.gstatic.com/s/spacegrotesk/v22/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj7oUUsj.ttf") format("truetype"); } @font-face { font-display: swap; font-family: "Space Grotesk"; font-style: normal; font-weight: 500; src: url("https://fonts.gstatic.com/s/spacegrotesk/v22/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj7aUUsj.ttf") format("truetype"); } @font-face { font-display: swap; font-family: "Space Grotesk"; font-style: normal; font-weight: 700; src: url("https://fonts.gstatic.com/s/spacegrotesk/v22/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj4PVksj.ttf") format("truetype"); } @font-face { font-display: swap; font-family: "JetBrains Mono"; font-style: normal; font-weight: 400; src: url("https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPQ.ttf") format("truetype"); } @font-face { font-display: swap; font-family: "JetBrains Mono"; font-style: normal; font-weight: 500; src: url("https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8-qxjPQ.ttf") format("truetype"); } @font-face { font-display: swap; font-family: "JetBrains Mono"; font-style: normal; font-weight: 700; src: url("https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8L6tjPQ.ttf") format("truetype"); } .sc-button { align-items: center; backdrop-filter: blur(14px) saturate(128%); background: var(--sc-color-surface-raised); border: var(--sc-border); border-radius: var(--sc-radius-2); box-shadow: var(--sc-shadow-inset); color: var(--sc-color-text); cursor: pointer; display: inline-flex; font-family: var(--sc-font-sans); font-size: var(--sc-font-size-3); font-weight: var(--sc-font-weight-medium); gap: var(--sc-space-2); justify-content: center; line-height: 1; min-height: 2.25rem; position: relative; transition: background-color var(--sc-motion-duration-fast) var(--sc-motion-ease), border-color var(--sc-motion-duration-fast) var(--sc-motion-ease), color var(--sc-motion-duration-fast) var(--sc-motion-ease), box-shadow var(--sc-motion-duration-fast) var(--sc-motion-ease); user-select: none; white-space: nowrap; } .sc-button:focus-visible, .sc-input:focus-visible, .sc-textarea:focus-visible, .sc-tabs-trigger:focus-visible, .sc-dropdown-item:focus-visible, .sc-popover-content:focus-visible, .sc-dialog-content:focus-visible, .sc-toast-close:focus-visible, .sc-toast-action:focus-visible { outline: 2px solid var(--sc-color-focus); outline-offset: 2px; } .sc-button:disabled, .sc-button[aria-disabled="true"] { cursor: not-allowed; opacity: 0.48; } .sc-button--default { background: var(--sc-color-accent-green); border-color: var(--sc-color-accent-green); color: #07120f; } .sc-button--default:hover:not(:disabled) { background: color-mix(in srgb, var(--sc-color-accent-green), white 14%); box-shadow: var(--sc-shadow-inset), 0 0 0 1px rgb(var(--sc-theme-rgb-primary) / 0.28); } .sc-button--secondary { background: color-mix(in srgb, var(--sc-color-accent-blue), #07100f 58%); border-color: color-mix(in srgb, var(--sc-color-accent-blue), transparent 30%); } .sc-button--secondary:hover:not(:disabled) { background: color-mix(in srgb, var(--sc-color-accent-blue), #07100f 46%); } .sc-button--outline { background: rgb(var(--sc-theme-rgb-panel) / 0.055); border-color: var(--sc-color-border-strong); } .sc-button--outline:hover:not(:disabled) { background: rgb(var(--sc-theme-rgb-warm) / 0.12); border-color: rgb(var(--sc-theme-rgb-warm) / 0.55); color: var(--sc-color-warning); } .sc-button--ghost { background: transparent; border-color: transparent; box-shadow: none; color: var(--sc-color-text-muted); } .sc-button--ghost:hover:not(:disabled) { background: rgb(var(--sc-theme-rgb-warm) / 0.1); border-color: rgb(var(--sc-theme-rgb-warm) / 0.4); color: var(--sc-color-warning); } .sc-button--danger { background: color-mix(in srgb, var(--sc-color-danger), #080807 56%); border-color: color-mix(in srgb, var(--sc-color-danger), transparent 28%); color: #fff3ee; } .sc-button--sm { min-height: 1.875rem; padding: 0 var(--sc-space-3); } .sc-button--md { padding: 0 var(--sc-space-4); } .sc-button--lg { min-height: 2.75rem; padding: 0 var(--sc-space-5); } .sc-input, .sc-textarea { backdrop-filter: blur(12px) saturate(120%); background: rgba(2, 8, 7, 0.62); border: var(--sc-border); border-radius: var(--sc-radius-2); box-shadow: var(--sc-shadow-inset); color: var(--sc-color-text); display: flex; font-family: var(--sc-font-sans); font-size: var(--sc-font-size-3); line-height: var(--sc-line-height-normal); transition: border-color var(--sc-motion-duration-fast) var(--sc-motion-ease), box-shadow var(--sc-motion-duration-fast) var(--sc-motion-ease); width: 100%; } .sc-input { height: 2.5rem; padding: 0 var(--sc-space-3); } .sc-textarea { min-height: 6.5rem; padding: var(--sc-space-3); resize: vertical; } .sc-input::placeholder, .sc-textarea::placeholder { color: var(--sc-color-text-subtle); } .sc-input:disabled, .sc-textarea:disabled { background: var(--sc-color-surface-inset); color: var(--sc-color-text-subtle); cursor: not-allowed; } .sc-checkbox, .sc-switch { align-items: flex-start; color: var(--sc-color-text); cursor: pointer; display: inline-flex; gap: var(--sc-space-3); line-height: var(--sc-line-height-normal); min-width: 0; position: relative; touch-action: manipulation; } .sc-checkbox__input, .sc-switch__input { height: 1px; opacity: 0; position: absolute; width: 1px; } .sc-checkbox__box { align-items: center; background: rgba(2, 8, 7, 0.68); border: var(--sc-border); border-radius: var(--sc-radius-1); box-shadow: var(--sc-shadow-inset); display: inline-flex; flex: 0 0 auto; height: 1.125rem; justify-content: center; margin-top: 0.125rem; transition: background-color var(--sc-motion-duration-fast) var(--sc-motion-ease), border-color var(--sc-motion-duration-fast) var(--sc-motion-ease), box-shadow var(--sc-motion-duration-fast) var(--sc-motion-ease); width: 1.125rem; } .sc-checkbox__box::after { border-bottom: 2px solid #06100c; border-right: 2px solid #06100c; content: ""; height: 0.55rem; opacity: 0; transform: rotate(42deg) translate(-1px, -1px); width: 0.32rem; } .sc-checkbox__input:checked + .sc-checkbox__box { background: var(--sc-color-accent-green); border-color: color-mix(in srgb, var(--sc-color-accent-green), white 18%); } .sc-checkbox__input:checked + .sc-checkbox__box::after { opacity: 1; } .sc-checkbox__input:focus-visible + .sc-checkbox__box, .sc-switch__input:focus-visible + .sc-switch__track, .sc-accordion__trigger:focus-visible { outline: 2px solid var(--sc-color-focus); outline-offset: 2px; } .sc-checkbox__input:disabled + .sc-checkbox__box, .sc-switch__input:disabled + .sc-switch__track { cursor: not-allowed; opacity: 0.46; } .sc-checkbox__copy, .sc-switch__copy { display: grid; gap: 0.125rem; min-width: 0; } .sc-checkbox__label, .sc-switch__label { font-size: var(--sc-font-size-3); font-weight: var(--sc-font-weight-medium); } .sc-checkbox__description, .sc-switch__description { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); } .sc-switch { align-items: center; } .sc-switch__track { background: rgba(2, 8, 7, 0.72); border: var(--sc-border); border-radius: 999px; box-shadow: var(--sc-shadow-inset); display: inline-flex; flex: 0 0 auto; height: 1.35rem; padding: 0.15rem; transition: background-color var(--sc-motion-duration-fast) var(--sc-motion-ease), border-color var(--sc-motion-duration-fast) var(--sc-motion-ease); width: 2.5rem; } .sc-switch__thumb { background: var(--sc-color-text-muted); border-radius: 999px; height: 1rem; transform: translateX(0); transition: background-color var(--sc-motion-duration-fast) var(--sc-motion-ease), transform var(--sc-motion-duration-fast) var(--sc-motion-ease); width: 1rem; } .sc-switch__input:checked + .sc-switch__track { background: color-mix(in srgb, var(--sc-color-accent-green), #06100c 46%); border-color: color-mix(in srgb, var(--sc-color-accent-green), transparent 26%); } .sc-switch__input:checked + .sc-switch__track .sc-switch__thumb { background: #f4fff5; transform: translateX(1.15rem); } .sc-accordion { border: var(--sc-border); border-radius: var(--sc-radius-2); overflow: hidden; } .sc-accordion__item { background: rgb(var(--sc-theme-rgb-panel) / 0.045); border-bottom: var(--sc-border); } .sc-accordion__item:last-child { border-bottom: 0; } .sc-accordion__trigger { align-items: center; color: var(--sc-color-text); cursor: pointer; display: flex; font-weight: var(--sc-font-weight-medium); justify-content: space-between; list-style: none; min-height: 2.75rem; padding: 0 var(--sc-space-4); touch-action: manipulation; } .sc-accordion__trigger::-webkit-details-marker { display: none; } .sc-accordion__marker { border-right: 1px solid var(--sc-color-text-muted); border-bottom: 1px solid var(--sc-color-text-muted); height: 0.45rem; transform: rotate(45deg); transition: transform var(--sc-motion-duration-fast) var(--sc-motion-ease); width: 0.45rem; } .sc-accordion__item[open] .sc-accordion__marker { transform: rotate(225deg) translate(-2px, -2px); } .sc-accordion__content { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-3); line-height: var(--sc-line-height-normal); padding: 0 var(--sc-space-4) var(--sc-space-4); } .sc-tooltip { display: inline-flex; position: relative; } .sc-tooltip__content { background: rgba(2, 8, 7, 0.96); border: var(--sc-border); border-radius: var(--sc-radius-1); box-shadow: var(--sc-shadow-1); color: var(--sc-color-text); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); left: 50%; line-height: var(--sc-line-height-normal); max-width: 14rem; opacity: 0; padding: var(--sc-space-2) var(--sc-space-3); pointer-events: none; position: absolute; transform: translateX(-50%); transition: opacity var(--sc-motion-duration-fast) var(--sc-motion-ease); visibility: hidden; white-space: nowrap; z-index: 60; } .sc-tooltip--top .sc-tooltip__content { bottom: calc(100% + var(--sc-space-2)); } .sc-tooltip--bottom .sc-tooltip__content { top: calc(100% + var(--sc-space-2)); } .sc-tooltip:hover .sc-tooltip__content, .sc-tooltip:focus-within .sc-tooltip__content { opacity: 1; visibility: visible; } .sc-skeleton { background: linear-gradient(90deg, transparent, rgb(var(--sc-theme-rgb-text) / 0.08), transparent), rgb(var(--sc-theme-rgb-panel) / 0.07); background-size: 200% 100%; border-radius: var(--sc-radius-1); min-height: 1rem; } .sc-separator { background: var(--sc-color-border); flex: 0 0 auto; } .sc-separator--horizontal { height: 1px; width: 100%; } .sc-separator--vertical { align-self: stretch; min-height: 1rem; width: 1px; } @keyframes sc-skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } .sc-card { backdrop-filter: blur(18px) saturate(120%); background: var(--sc-color-surface); border: var(--sc-border); border-radius: var(--sc-radius-3); box-shadow: var(--sc-shadow-1), var(--sc-shadow-inset); color: var(--sc-color-text); font-family: var(--sc-font-sans); overflow: hidden; position: relative; } .sc-card::before { background: linear-gradient(90deg, transparent, rgb(var(--sc-theme-rgb-primary) / 0.22), transparent); content: ""; height: 1px; left: 0; position: absolute; right: 0; top: 0; } .sc-card-header, .sc-card-content, .sc-card-footer { padding: var(--sc-space-4); } .sc-card-header { border-bottom: var(--sc-border); } .sc-card-title { font-size: var(--sc-font-size-5); font-weight: var(--sc-font-weight-bold); line-height: var(--sc-line-height-tight); margin: 0; } .sc-card-description { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-3); line-height: var(--sc-line-height-normal); margin: var(--sc-space-1) 0 0; } .sc-card-footer { align-items: center; border-top: var(--sc-border); display: flex; gap: var(--sc-space-2); } .sc-badge { align-items: center; backdrop-filter: blur(10px); border: var(--sc-border); border-radius: var(--sc-radius-1); display: inline-flex; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); font-weight: var(--sc-font-weight-medium); gap: var(--sc-space-1); line-height: 1; min-height: 1.25rem; padding: 0 var(--sc-space-2); text-transform: uppercase; white-space: nowrap; } .sc-badge--default { background: var(--sc-color-accent-blue-soft); border-color: color-mix(in srgb, var(--sc-color-accent-blue), transparent 42%); color: #d7efff; } .sc-badge--green { background: var(--sc-color-accent-green-soft); border-color: color-mix(in srgb, var(--sc-color-accent-green), transparent 40%); color: #d9ffe2; } .sc-badge--warning { background: var(--sc-color-warning-soft); border-color: color-mix(in srgb, var(--sc-color-warning), transparent 36%); color: #ffd0ad; } .sc-badge--danger { background: var(--sc-color-danger-soft); border-color: color-mix(in srgb, var(--sc-color-danger), transparent 34%); color: #ffc4b8; } .sc-badge--neutral { background: rgba(240, 255, 246, 0.08); color: var(--sc-color-text-muted); } .sc-tabs { color: var(--sc-color-text); font-family: var(--sc-font-sans); } .sc-tabs-list { align-items: end; border-bottom: var(--sc-border); display: flex; gap: var(--sc-space-1); } .sc-tabs-trigger { background: transparent; border: 0; border-bottom: 1px solid transparent; color: var(--sc-color-text-muted); cursor: pointer; font: inherit; margin-bottom: -1px; min-height: 2.25rem; padding: 0 var(--sc-space-3); } .sc-tabs-trigger[data-state="active"] { border-bottom-color: var(--sc-color-accent-green); color: var(--sc-color-text); } .sc-tabs-trigger:disabled { cursor: not-allowed; opacity: 0.5; } .sc-tabs-content { padding-top: var(--sc-space-4); } .sc-dialog-overlay { backdrop-filter: blur(10px); background: var(--sc-color-overlay); inset: 0; position: fixed; z-index: 50; } .sc-dialog-content, .sc-dropdown-content, .sc-popover-content, .sc-toast-root { backdrop-filter: blur(20px) saturate(132%); background: var(--sc-color-surface-raised); border: var(--sc-border-strong); border-radius: var(--sc-radius-3); box-shadow: var(--sc-shadow-2), var(--sc-shadow-inset); color: var(--sc-color-text); font-family: var(--sc-font-sans); } .sc-dialog-content { left: 50%; max-width: min(calc(100vw - 2rem), 30rem); padding: var(--sc-space-5); position: fixed; top: 50%; transform: translate(-50%, -50%); width: 100%; z-index: 51; } .sc-dialog-title { font-size: var(--sc-font-size-5); line-height: var(--sc-line-height-tight); margin: 0; } .sc-dialog-description { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-3); line-height: var(--sc-line-height-normal); margin: var(--sc-space-2) 0 0; } .sc-dialog-footer, .sc-dialog-header { display: flex; gap: var(--sc-space-2); } .sc-dialog-header { flex-direction: column; padding-right: var(--sc-space-8); } .sc-dialog-footer { justify-content: flex-end; margin-top: var(--sc-space-5); } .sc-dialog-close { height: 1.875rem; padding: 0; position: absolute; right: var(--sc-space-3); top: var(--sc-space-3); width: 1.875rem; } .sc-dropdown-content { min-width: 12rem; padding: var(--sc-space-1); z-index: 60; } .sc-dropdown-label { color: var(--sc-color-text-muted); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); padding: var(--sc-space-2) var(--sc-space-3); } .sc-dropdown-item { align-items: center; border-radius: var(--sc-radius-1); color: var(--sc-color-text); cursor: default; display: flex; font-size: var(--sc-font-size-3); min-height: 2rem; padding: 0 var(--sc-space-3); user-select: none; } .sc-dropdown-item[data-highlighted] { background: rgb(var(--sc-theme-rgb-warm) / 0.16); color: var(--sc-color-warning); } .sc-dropdown-item[data-disabled] { color: var(--sc-color-text-subtle); pointer-events: none; } .sc-dropdown-separator { background: var(--sc-color-border); height: 1px; margin: var(--sc-space-1); } .sc-popover-content { max-width: min(calc(100vw - 2rem), 20rem); padding: var(--sc-space-4); z-index: 60; } .sc-toast-viewport { bottom: var(--sc-space-4); display: flex; flex-direction: column; gap: var(--sc-space-2); list-style: none; margin: 0; max-width: min(calc(100vw - 2rem), 24rem); padding: 0; position: fixed; right: var(--sc-space-4); width: 100%; z-index: 70; } .sc-toast-root { display: grid; gap: var(--sc-space-1) var(--sc-space-3); grid-template-columns: 1fr auto; padding: var(--sc-space-3); } .sc-toast-title { font-size: var(--sc-font-size-3); font-weight: var(--sc-font-weight-bold); line-height: var(--sc-line-height-tight); } .sc-toast-description { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); grid-column: 1; line-height: var(--sc-line-height-normal); } .sc-toast-action { grid-column: 2; grid-row: 1 / span 2; } .sc-toast-close { align-items: center; background: transparent; border: 0; border-radius: var(--sc-radius-1); color: var(--sc-color-text-muted); cursor: pointer; display: inline-flex; font: inherit; grid-column: 2; grid-row: 1; height: 1.5rem; justify-content: center; padding: var(--sc-space-1); width: 1.5rem; } .sc-ascii-hero, .sc-glass-panel, .sc-glass-card, .sc-community-badge, .sc-logo-marquee, .sc-mock-console, .sc-mock-ide, .sc-pricing-card, .sc-prompt-hero, .sc-chat-dock, .sc-prompt-box, .sc-before-after, .sc-sticky-banner { backdrop-filter: blur(18px) saturate(126%); background: var(--sc-color-surface); border: var(--sc-border); border-radius: var(--sc-radius-3); box-shadow: var(--sc-shadow-1), var(--sc-shadow-inset); color: var(--sc-color-text); font-family: var(--sc-font-sans); } .sc-ascii-hero { display: grid; gap: var(--sc-space-5); grid-template-columns: minmax(0, 0.75fr) minmax(18rem, 1fr); min-height: 18rem; overflow: hidden; padding: var(--sc-space-5); position: relative; } .sc-ascii-hero--bare { background: transparent; border: 0; border-radius: 0; box-shadow: none; min-height: 100%; padding: 0; } .sc-ascii-hero--banner { display: block; min-height: 13rem; } .sc-ascii-hero--banner .sc-ascii-hero__copy { bottom: var(--sc-space-5); left: var(--sc-space-5); max-width: 34rem; position: absolute; right: var(--sc-space-5); } .sc-ascii-hero--banner .sc-ascii-hero__copy::before { background: radial-gradient(130% 150% at 0% 100%, rgba(2, 8, 7, 0.86), transparent 62%); content: ""; inset: -2.5rem -3rem -2.5rem -2.5rem; position: absolute; z-index: -1; } .sc-ascii-hero--banner .sc-ascii-hero__title { text-shadow: 0 2px 18px rgba(2, 8, 7, 0.9); } .sc-ascii-hero--banner .sc-ascii-hero__subtitle { text-shadow: 0 1px 12px rgba(2, 8, 7, 0.95); } .sc-ascii-hero::before, .sc-glass-panel::before, .sc-glass-card::before, .sc-community-badge::before, .sc-mock-console::before, .sc-mock-ide::before, .sc-pricing-card::before, .sc-prompt-hero::before, .sc-chat-dock::before, .sc-before-after::before { background: linear-gradient(90deg, transparent, rgb(var(--sc-theme-rgb-primary) / 0.24), transparent); content: ""; height: 1px; left: 0; position: absolute; right: 0; top: 0; } .sc-ascii-hero__canvas { height: 100%; inset: 0; opacity: 0.74; pointer-events: none; position: absolute; width: 100%; } .sc-ascii-hero__copy { align-self: end; position: relative; z-index: 1; } .sc-ascii-hero__label, .sc-glass-panel__kicker, .sc-chat-dock__header, .sc-before-after__label, .sc-sticky-banner__content, .sc-signal-marquee__item { color: var(--sc-color-accent-green); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); } .sc-ascii-hero__title { font-size: var(--sc-font-size-6); line-height: var(--sc-line-height-tight); margin: var(--sc-space-2) 0; } .sc-ascii-hero__subtitle { color: var(--sc-color-text-muted); line-height: var(--sc-line-height-normal); margin: 0; } .sc-ascii-hero__art { align-self: stretch; background: linear-gradient(rgb(var(--sc-theme-rgb-secondary) / 0.16) 1px, transparent 1px) 0 0 / 100% 25%, linear-gradient(90deg, rgb(var(--sc-theme-rgb-secondary) / 0.16) 1px, transparent 1px) 0 0 / 25% 100%, rgba(2, 8, 7, 0.5); border: var(--sc-border); border-radius: var(--sc-radius-2); color: rgba(232, 255, 238, 0.9); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); line-height: 1.35; margin: 0; overflow: auto; padding: var(--sc-space-4); position: relative; white-space: pre; z-index: 1; } .sc-aurora-field { background: radial-gradient(circle at 20% 30%, rgb(var(--sc-theme-rgb-primary) / 0.42), transparent 18rem), radial-gradient(circle at 72% 48%, rgb(var(--sc-theme-rgb-secondary) / 0.34), transparent 20rem), linear-gradient(135deg, rgb(var(--sc-theme-rgb-warm) / 0.12), transparent 34%), rgba(2, 8, 7, 0.28); filter: blur(2px) saturate(126%); inset: 0; opacity: 0.86; pointer-events: none; position: absolute; } .sc-aurora-field--low { opacity: 0.45; } .sc-aurora-field--high { opacity: 1; } .sc-node-grid { border: var(--sc-border); border-radius: var(--sc-radius-3); min-height: 16rem; overflow: hidden; position: relative; } .sc-node-grid__field { background: linear-gradient(rgb(var(--sc-theme-rgb-secondary) / 0.18) 1px, transparent 1px) 0 0 / 100% 4rem, linear-gradient(90deg, rgb(var(--sc-theme-rgb-secondary) / 0.18) 1px, transparent 1px) 0 0 / 4rem 100%, rgba(1, 8, 7, 0.42); inset: 0; position: absolute; } .sc-node-grid--loose .sc-node-grid__field { background-size: 100% 5.5rem, 5.5rem 100%, auto; } .sc-node-grid--tight .sc-node-grid__field { background-size: 100% 2.75rem, 2.75rem 100%, auto; } .sc-node-grid__node { background: var(--sc-color-accent-green); border-radius: 50%; box-shadow: 0 0 18px rgb(var(--sc-theme-rgb-primary) / 0.42); height: 5px; position: absolute; transform: translate(-50%, -50%); width: 5px; } .sc-node-grid__node--blue { background: var(--sc-color-accent-blue); box-shadow: 0 0 18px rgb(var(--sc-theme-rgb-secondary) / 0.5); } .sc-node-grid__node--warning { background: var(--sc-color-warning); box-shadow: 0 0 18px var(--sc-color-warning-soft); } .sc-node-grid__node--danger { background: var(--sc-color-danger); box-shadow: 0 0 18px var(--sc-color-danger-soft); } .sc-node-grid__node-label { color: var(--sc-color-text-muted); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); left: 0.6rem; position: absolute; top: -0.4rem; white-space: nowrap; } .sc-node-grid__node:nth-child(1) { left: 8%; top: 18%; } .sc-node-grid__node:nth-child(2) { left: 25%; top: 42%; } .sc-node-grid__node:nth-child(3) { left: 44%; top: 24%; } .sc-node-grid__node:nth-child(4) { left: 61%; top: 64%; } .sc-node-grid__node:nth-child(5) { left: 78%; top: 28%; } .sc-node-grid__node:nth-child(6) { left: 88%; top: 76%; } .sc-node-grid__node:nth-child(7) { left: 18%; top: 78%; } .sc-node-grid__node:nth-child(8) { left: 34%; top: 12%; } .sc-node-grid__node:nth-child(9) { left: 51%; top: 82%; } .sc-node-grid__node:nth-child(10) { left: 70%; top: 44%; } .sc-node-grid__node:nth-child(11) { left: 92%; top: 12%; } .sc-node-grid__node:nth-child(12) { left: 6%; top: 58%; } .sc-node-grid__content { inset: 0; padding: var(--sc-space-4); position: absolute; } .sc-glass-panel { overflow: hidden; position: relative; } .sc-glass-panel__header { border-bottom: var(--sc-border); display: grid; gap: var(--sc-space-1); padding: var(--sc-space-4); } .sc-glass-panel__title { font-size: var(--sc-font-size-5); line-height: var(--sc-line-height-tight); margin: 0; } .sc-glass-panel__body { color: var(--sc-color-text-muted); line-height: var(--sc-line-height-normal); padding: var(--sc-space-4); } .sc-glass-card { display: grid; gap: var(--sc-space-3); min-height: 12rem; overflow: hidden; padding: var(--sc-space-4); position: relative; } .sc-glass-card--glow-hover:hover { border-color: rgb(var(--sc-theme-rgb-warm) / 0.5); } .sc-glass-card--breathing { animation: sc-glass-card-breathe 5.5s ease-in-out infinite; } .sc-glass-card__icon { align-items: center; background: rgb(var(--sc-theme-rgb-primary) / 0.1); border: var(--sc-border); border-radius: var(--sc-radius-1); color: var(--sc-color-accent-green); display: inline-flex; font-family: var(--sc-font-mono); height: 2rem; justify-content: center; width: 2rem; } .sc-glass-card__title { font-size: var(--sc-font-size-5); line-height: var(--sc-line-height-tight); margin: 0; } .sc-glass-card__body { color: var(--sc-color-text-muted); line-height: var(--sc-line-height-normal); margin: 0; } .sc-glass-card__link { align-items: center; color: var(--sc-color-accent-green); display: inline-flex; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); gap: var(--sc-space-2); justify-self: start; margin-top: auto; text-decoration: none; } .sc-token-stream { align-items: center; color: var(--sc-color-accent-green); display: inline-flex; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); gap: 0; } .sc-token-stream__token:nth-child(3n) { color: var(--sc-color-accent-blue); } .sc-token-stream__cursor { background: var(--sc-color-accent-green); display: inline-block; height: 1em; margin-left: 0.25rem; width: 0.45em; } .sc-gradient-text { background: linear-gradient(90deg, var(--sc-color-text), var(--sc-color-accent-green), var(--sc-color-accent-blue)); background-clip: text; color: transparent; display: inline; } .sc-gradient-text--animate { background-size: 180% 100%; } .sc-status-dot { background: var(--sc-color-text-subtle); border-radius: 50%; color: var(--sc-color-text-subtle); display: inline-block; height: 0.625rem; width: 0.625rem; } .sc-status-dot--green { background: var(--sc-color-accent-green); color: var(--sc-color-accent-green); } .sc-status-dot--blue { background: var(--sc-color-accent-blue); color: var(--sc-color-accent-blue); } .sc-status-dot--warning { background: var(--sc-color-warning); color: var(--sc-color-warning); } .sc-status-dot--danger { background: var(--sc-color-danger); color: var(--sc-color-danger); } .sc-status-dot--neutral { background: var(--sc-color-text-subtle); color: var(--sc-color-text-subtle); } .sc-rotator, .sc-word-roll { color: var(--sc-color-accent-green); font-family: var(--sc-font-mono); } .sc-rotator__cursor { color: var(--sc-color-accent-green); margin-left: 0.125rem; } .sc-rotator__cursor--block { animation: sc-caret-blink 1s steps(1, end) infinite; background: var(--sc-color-accent-green); display: inline-block; height: 1em; transform: translateY(0.12em); width: 0.45em; } .sc-mock-console { overflow: hidden; position: relative; } .sc-mock-console__bar { align-items: center; border-bottom: var(--sc-border); color: var(--sc-color-text-muted); display: flex; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); justify-content: space-between; min-height: 2.25rem; padding: 0 var(--sc-space-3); } .sc-mock-console__body { background: rgba(1, 6, 5, 0.42); color: var(--sc-color-text); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); line-height: 1.65; margin: 0; overflow: auto; padding: var(--sc-space-4); } .sc-mock-console__token--comment { color: var(--sc-color-text-subtle); } .sc-mock-console__token--keyword { color: var(--sc-color-accent-blue); } .sc-mock-console__token--string { color: var(--sc-color-accent-green); } .sc-mock-console__token--function { color: #eef6ee; } .sc-mock-console__token--number { color: var(--sc-color-warning); } .sc-mock-ide { overflow: hidden; position: relative; z-index: 21; } .sc-mock-ide__chrome { align-items: center; border-bottom: var(--sc-border); color: var(--sc-color-text-muted); display: flex; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); gap: var(--sc-space-2); min-height: 2.5rem; padding: 0 var(--sc-space-3); } .sc-mock-ide__dot { background: rgb(var(--sc-theme-rgb-text) / 0.34); border-radius: 50%; height: 0.625rem; width: 0.625rem; } .sc-mock-ide__dot:nth-child(1) { background: var(--sc-color-danger); } .sc-mock-ide__dot:nth-child(2) { background: var(--sc-color-warning); } .sc-mock-ide__dot:nth-child(3) { background: var(--sc-color-accent-green); } .sc-mock-ide__tab { background: rgb(var(--sc-theme-rgb-panel) / 0.055); border: var(--sc-border); border-bottom: 0; border-radius: var(--sc-radius-1) var(--sc-radius-1) 0 0; color: var(--sc-color-text); margin-left: var(--sc-space-2); padding: var(--sc-space-1) var(--sc-space-2); } .sc-mock-ide__thinking { align-items: center; color: var(--sc-color-accent-green); display: inline-flex; gap: var(--sc-space-1); margin-left: auto; } .sc-mock-ide__spinner { animation: sc-spinner 1150ms linear infinite; border: 1px solid rgb(var(--sc-theme-rgb-primary) / 0.28); border-radius: 50%; border-top-color: var(--sc-color-accent-green); height: 0.75rem; width: 0.75rem; } .sc-mock-ide__body { --sc-mock-ide-line-height: 1.5rem; background: linear-gradient( transparent calc(var(--sc-mock-ide-line-height) - 1px), rgb(var(--sc-theme-rgb-secondary) / 0.14) calc(var(--sc-mock-ide-line-height) - 1px), rgb(var(--sc-theme-rgb-secondary) / 0.14) var(--sc-mock-ide-line-height) ) 0 var(--sc-space-4) / 100% var(--sc-mock-ide-line-height), rgba(1, 6, 5, 0.48); color: var(--sc-color-text); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); line-height: var(--sc-mock-ide-line-height); margin: 0; min-height: 14rem; overflow: auto; padding: var(--sc-space-4); } .sc-mock-ide__body code { display: block; font: inherit; line-height: inherit; } .sc-mock-ide__caret { animation: sc-caret-blink 1s steps(1, end) infinite; background: var(--sc-color-accent-green); display: inline-block; height: 1em; margin-left: 0.125rem; transform: translateY(0.16em); width: 0.45em; } .sc-mock-ide__token--comment { color: var(--sc-color-text-subtle); } .sc-mock-ide__token--keyword { color: var(--sc-color-accent-blue); } .sc-mock-ide__token--string { color: var(--sc-color-accent-green); } .sc-mock-ide__token--function { color: #eef6ee; } .sc-mock-ide__token--number { color: var(--sc-color-warning); } .sc-mock-ide__token--text { color: var(--sc-color-text); } .sc-chat-bubble { border: var(--sc-border); border-radius: var(--sc-radius-2); max-width: 28rem; padding: var(--sc-space-3); } .sc-chat-bubble--system { background: rgb(var(--sc-theme-rgb-panel) / 0.06); } .sc-chat-bubble--user { background: rgb(var(--sc-theme-rgb-secondary) / 0.12); margin-left: auto; } .sc-chat-bubble--signal { background: rgb(var(--sc-theme-rgb-primary) / 0.12); } .sc-chat-bubble__meta { align-items: center; color: var(--sc-color-text-subtle); display: flex; flex-wrap: wrap; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); gap: var(--sc-space-2); margin-bottom: var(--sc-space-2); } .sc-chat-bubble__icon { align-items: center; color: var(--sc-color-accent-green); display: inline-flex; } .sc-chat-bubble__agent { color: var(--sc-color-text); font-weight: var(--sc-font-weight-medium); } .sc-chat-bubble__thinking { align-items: center; background: rgb(var(--sc-theme-rgb-primary) / 0.08); border: var(--sc-border); border-radius: var(--sc-radius-round); color: var(--sc-color-text-muted); display: inline-flex; gap: 6px; padding: 2px var(--sc-space-2); } .sc-chat-bubble__thinking .sc-spinner__ring { border-width: 2px; height: 0.7rem; width: 0.7rem; } .sc-chat-bubble__body { color: var(--sc-color-text); font-size: var(--sc-font-size-3); line-height: var(--sc-line-height-normal); } .sc-chat-dock { overflow: hidden; } .sc-chat-dock__header { border-bottom: var(--sc-border); padding: var(--sc-space-3); } .sc-chat-dock__body { display: grid; gap: var(--sc-space-2); padding: var(--sc-space-3); } .sc-prompt-box { align-items: center; display: grid; gap: var(--sc-space-2); grid-template-columns: auto 1fr auto auto; padding: var(--sc-space-2); } .sc-prompt-box__input { background: transparent; border: 0; color: var(--sc-color-text); font: inherit; min-width: 0; outline: 0; } .sc-prompt-box:focus-within { outline: 2px solid var(--sc-color-focus); outline-offset: 2px; } .sc-prompt-box__input::placeholder { color: var(--sc-color-text-subtle); } .sc-prompt-box__icon, .sc-prompt-box__send { align-items: center; background: rgb(var(--sc-theme-rgb-panel) / 0.06); border: var(--sc-border); border-radius: var(--sc-radius-1); color: var(--sc-color-text-muted); cursor: pointer; display: inline-flex; height: 2rem; justify-content: center; width: 2rem; } .sc-prompt-box__send { background: rgb(var(--sc-theme-rgb-primary) / 0.14); color: var(--sc-color-text); } .sc-prompt-hero { align-items: center; display: grid; gap: var(--sc-space-2); grid-template-columns: auto minmax(0, 1fr) auto; min-height: 4.25rem; overflow: hidden; padding: var(--sc-space-3); position: relative; } .sc-prompt-hero__mark { align-items: center; background: rgb(var(--sc-theme-rgb-primary) / 0.1); border: var(--sc-border); border-radius: var(--sc-radius-1); color: var(--sc-color-accent-green); display: inline-flex; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); height: 2.25rem; justify-content: center; width: 2.25rem; } .sc-prompt-hero__input { background: transparent; border: 0; color: var(--sc-color-text); font: inherit; min-width: 0; outline: 0; } .sc-prompt-hero:focus-within { outline: 2px solid var(--sc-color-focus); outline-offset: 2px; } .sc-prompt-hero__input::placeholder { color: var(--sc-color-text-subtle); } .sc-logo-marquee { overflow: hidden; padding: var(--sc-space-3) 0; } .sc-logo-marquee--fade { mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent); } .sc-logo-marquee__track { align-items: center; display: flex; width: max-content; } .sc-logo-marquee--pause-on-hover:hover .sc-logo-marquee__track { animation-play-state: paused; } .sc-logo-marquee__item { align-items: center; color: var(--sc-color-text-muted); display: inline-flex; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); margin-right: var(--sc-logo-marquee-gap, 3rem); min-height: 1.75rem; white-space: nowrap; } .sc-logo-marquee__item img { display: block; max-height: 1.5rem; max-width: 8rem; opacity: 0.78; } .sc-community-badge { align-items: center; display: flex; gap: var(--sc-space-3); min-height: 5rem; overflow: hidden; padding: var(--sc-space-3); position: relative; text-decoration: none; } .sc-community-badge__icon { align-items: center; background: rgb(var(--sc-theme-rgb-primary) / 0.1); border: var(--sc-border); border-radius: var(--sc-radius-1); color: var(--sc-color-accent-green); display: inline-flex; flex: 0 0 auto; font-family: var(--sc-font-mono); height: 2.25rem; justify-content: center; width: 2.25rem; } .sc-community-badge__icon img { display: block; filter: invert(1); height: 1.1rem; opacity: 0.86; width: 1.1rem; } .sc-community-badge__copy { display: grid; gap: var(--sc-space-1); min-width: 0; } .sc-community-badge__title { color: var(--sc-color-text); font-weight: var(--sc-font-weight-bold); line-height: var(--sc-line-height-tight); } .sc-community-badge__subtitle { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); line-height: var(--sc-line-height-normal); } .sc-pricing-card { display: grid; gap: var(--sc-space-3); min-height: 20rem; overflow: hidden; padding: var(--sc-space-4); position: relative; } .sc-pricing-card--featured { border-color: var(--sc-color-border-strong); box-shadow: var(--sc-shadow-1), var(--sc-shadow-inset), 0 0 0 1px rgb(var(--sc-theme-rgb-primary) / 0.14); } .sc-pricing-card__flag { color: var(--sc-color-accent-green); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); min-height: 1.25rem; } .sc-pricing-card__tier { font-size: var(--sc-font-size-5); font-weight: var(--sc-font-weight-bold); line-height: var(--sc-line-height-tight); } .sc-pricing-card__amount { align-items: baseline; color: var(--sc-color-text); display: flex; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-6); font-weight: var(--sc-font-weight-bold); gap: var(--sc-space-2); line-height: 1; } .sc-pricing-card__unit { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); font-weight: var(--sc-font-weight-regular); } .sc-pricing-card__blurb { color: var(--sc-color-text-muted); line-height: var(--sc-line-height-normal); margin: 0; } .sc-pricing-card__features { color: var(--sc-color-text); display: grid; gap: var(--sc-space-2); list-style: none; margin: 0; padding: 0; } .sc-pricing-card__features li { border-top: var(--sc-border); padding-top: var(--sc-space-2); } .sc-pricing-card__cta { align-self: end; margin-top: auto; text-decoration: none; } .sc-before-after { display: grid; grid-template-columns: 1fr auto 1fr; overflow: hidden; position: relative; } .sc-before-after__pane { min-height: 11rem; padding: var(--sc-space-4); } .sc-before-after__pane--after { background: radial-gradient(circle at 70% 18%, rgb(var(--sc-theme-rgb-primary) / 0.24), transparent 8rem), rgb(var(--sc-theme-rgb-primary) / 0.07); } .sc-before-after__label { margin-bottom: var(--sc-space-3); } .sc-before-after__body { color: var(--sc-color-text); line-height: var(--sc-line-height-normal); } .sc-before-after__divider { background: var(--sc-color-border-strong); width: 1px; } .sc-word-roll { color: var(--sc-color-accent-green); font-family: var(--sc-font-mono); } .sc-stat-counter { color: var(--sc-color-text); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-6); font-weight: var(--sc-font-weight-bold); line-height: 1; } .sc-signal-marquee { border: var(--sc-border); border-radius: var(--sc-radius-2); overflow: hidden; white-space: nowrap; } .sc-signal-marquee__track { display: inline-flex; gap: var(--sc-space-6); min-width: 200%; padding: var(--sc-space-2) 0; } .sc-signal-marquee__item { display: inline-block; } .sc-status-indicator { background: var(--sc-color-text-subtle); border-radius: 50%; display: inline-block; height: 0.5rem; width: 0.5rem; } .sc-status-indicator--green { background: var(--sc-color-accent-green); } .sc-status-indicator--blue { background: var(--sc-color-accent-blue); } .sc-status-indicator--warning { background: var(--sc-color-warning); } .sc-status-indicator--danger { background: var(--sc-color-danger); } .sc-status-indicator--neutral { background: var(--sc-color-text-subtle); } .sc-status-indicator--pulse { box-shadow: 0 0 0 0 color-mix(in srgb, currentColor, transparent 52%); } .sc-sticky-banner { align-items: center; display: flex; gap: var(--sc-space-3); justify-content: space-between; padding: var(--sc-space-3) var(--sc-space-4); } .sc-sticky-banner__content { color: var(--sc-color-text); } @media (prefers-reduced-motion: no-preference) { .sc-token-stream__cursor { animation: sc-caret-blink 1s steps(1, end) infinite; } .sc-gradient-text--animate { animation: sc-gradient-shift 11s linear infinite; } .sc-logo-marquee__track { animation: sc-logo-marquee var(--sc-logo-marquee-speed, 42s) linear infinite; } .sc-signal-marquee__track { animation: sc-marquee 30s linear infinite; } .sc-status-dot--pulse, .sc-status-indicator--pulse { animation: sc-status-pulse 2.1s ease-in-out infinite; } .sc-skeleton { animation: sc-skeleton-pulse 1.9s ease-in-out infinite; } } @keyframes sc-caret-blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } } @keyframes sc-marquee { to { transform: translateX(-50%); } } @keyframes sc-logo-marquee { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } } @keyframes sc-gradient-shift { to { background-position: 180% 0; } } @keyframes sc-glass-card-breathe { 0%, 100% { box-shadow: var(--sc-shadow-1), var(--sc-shadow-inset); } 50% { box-shadow: var(--sc-shadow-1), var(--sc-shadow-inset), 0 0 0 1px rgb(var(--sc-theme-rgb-primary) / 0.16); } } @keyframes sc-spinner { to { transform: rotate(360deg); } } @keyframes sc-status-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgb(var(--sc-theme-rgb-primary) / 0.18); } 50% { box-shadow: 0 0 0 6px rgb(var(--sc-theme-rgb-primary) / 0); } } @media (max-width: 760px) { .sc-ascii-hero, .sc-before-after { grid-template-columns: 1fr; } .sc-before-after__divider { height: 1px; width: 100%; } } /* ============================================================ Label / Kbd ============================================================ */ .sc-label { align-items: center; color: var(--sc-color-text); display: inline-flex; font-size: var(--sc-font-size-2); font-weight: var(--sc-font-weight-medium); gap: 4px; } .sc-label__required { color: var(--sc-color-danger); } .sc-kbd { align-items: center; background: rgb(var(--sc-theme-rgb-panel) / 0.06); border: var(--sc-border); border-bottom-width: 2px; border-radius: var(--sc-radius-1); color: var(--sc-color-text-muted); display: inline-flex; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); height: 1.5rem; justify-content: center; line-height: 1; min-width: 1.5rem; padding: 0 0.4rem; } .sc-kbd-group { align-items: center; display: inline-flex; gap: 4px; } /* ============================================================ Spinner ============================================================ */ .sc-spinner { display: inline-flex; } .sc-spinner__ring { animation: sc-spin 1s linear infinite; border: 2px solid rgb(var(--sc-theme-rgb-primary) / 0.22); border-radius: 50%; border-top-color: var(--sc-color-accent-green); display: block; height: 1.25rem; width: 1.25rem; } .sc-spinner--sm .sc-spinner__ring { border-width: 2px; height: 0.9rem; width: 0.9rem; } .sc-spinner--lg .sc-spinner__ring { border-width: 3px; height: 1.85rem; width: 1.85rem; } @keyframes sc-spin { to { transform: rotate(360deg); } } /* ============================================================ Avatar ============================================================ */ .sc-avatar { align-items: center; background: rgb(var(--sc-theme-rgb-primary) / 0.12); border: var(--sc-border); border-radius: 50%; color: var(--sc-color-accent-green); display: inline-flex; flex: 0 0 auto; font-family: var(--sc-font-mono); font-weight: var(--sc-font-weight-bold); justify-content: center; overflow: hidden; } .sc-avatar--sm { font-size: var(--sc-font-size-1); height: 1.75rem; width: 1.75rem; } .sc-avatar--md { font-size: var(--sc-font-size-3); height: 2.5rem; width: 2.5rem; } .sc-avatar--lg { font-size: var(--sc-font-size-5); height: 3.5rem; width: 3.5rem; } .sc-avatar--blue { background: var(--sc-color-accent-blue-soft); color: var(--sc-color-accent-blue); } .sc-avatar--warm { background: var(--sc-color-warning-soft); color: var(--sc-color-warning); } .sc-avatar__image { height: 100%; object-fit: cover; width: 100%; } .sc-avatar__fallback { align-items: center; display: inline-flex; justify-content: center; } .sc-avatar-group { display: inline-flex; } .sc-avatar-group .sc-avatar { box-shadow: 0 0 0 2px var(--sc-color-background); } .sc-avatar-group .sc-avatar:not(:first-child) { margin-left: -0.6rem; } /* ============================================================ Progress ============================================================ */ .sc-progress { background: rgb(var(--sc-theme-rgb-panel) / 0.1); border: var(--sc-border); border-radius: var(--sc-radius-round); box-shadow: var(--sc-shadow-inset); height: 0.5rem; overflow: hidden; position: relative; width: 100%; } .sc-progress__bar { background: var(--sc-color-accent-green); border-radius: inherit; display: block; height: 100%; transition: width var(--sc-motion-duration-normal) var(--sc-motion-ease); } .sc-progress--blue .sc-progress__bar { background: var(--sc-color-accent-blue); } .sc-progress--warning .sc-progress__bar { background: var(--sc-color-warning); } .sc-progress--danger .sc-progress__bar { background: var(--sc-color-danger); } /* ============================================================ Alert ============================================================ */ .sc-alert { --sc-alert-rgb: var(--sc-theme-rgb-primary); align-items: flex-start; backdrop-filter: blur(12px); background: repeating-linear-gradient( 180deg, rgb(var(--sc-alert-rgb) / 0.05) 0, rgb(var(--sc-alert-rgb) / 0.05) 1px, transparent 1px, transparent 6px ), rgb(var(--sc-theme-rgb-panel) / 0.04); border: var(--sc-border); border-radius: var(--sc-radius-2); display: flex; gap: var(--sc-space-3); overflow: hidden; padding: var(--sc-space-3) var(--sc-space-4); position: relative; } /* Bottom data line: a lit segment travels across dim dashes, flashing them on and off one after another. */ .sc-alert::after { animation: sc-alert-line 3.6s linear infinite; background: linear-gradient(90deg, transparent, rgb(var(--sc-alert-rgb) / 0.95) 42%, rgb(var(--sc-alert-rgb) / 0.95) 58%, transparent), repeating-linear-gradient(90deg, rgb(var(--sc-alert-rgb) / 0.3) 0, rgb(var(--sc-alert-rgb) / 0.3) 6px, transparent 6px, transparent 14px); background-repeat: no-repeat, repeat-x; background-size: 26% 100%, 100% 100%; bottom: 0; content: ""; height: 2px; left: 0; pointer-events: none; position: absolute; right: 0; z-index: 0; } .sc-alert__icon, .sc-alert__body { position: relative; z-index: 1; } .sc-alert__icon { color: var(--sc-color-accent-green); display: inline-flex; flex: 0 0 auto; margin-top: 1px; } .sc-alert__body { display: grid; gap: 2px; min-width: 0; } .sc-alert__title { color: var(--sc-color-text); font-size: var(--sc-font-size-3); font-weight: var(--sc-font-weight-medium); margin: 0; } .sc-alert__description { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); line-height: var(--sc-line-height-normal); margin: 0; } .sc-alert--info { --sc-alert-rgb: var(--sc-theme-rgb-secondary); } .sc-alert--info .sc-alert__icon { color: var(--sc-color-accent-blue); } .sc-alert--warning { --sc-alert-rgb: var(--sc-theme-rgb-warm); } .sc-alert--warning .sc-alert__icon { color: var(--sc-color-warning); } .sc-alert--danger { --sc-alert-rgb: 255 74 74; } .sc-alert--danger .sc-alert__icon { color: var(--sc-color-danger); } .sc-alert--danger::after { animation-duration: 2.1s; } @keyframes sc-alert-line { 0% { background-position: -30% 0, 0 0; } 100% { background-position: 130% 0, 0 0; } } @media (prefers-reduced-motion: reduce) { .sc-alert::after { animation: none; } } /* ============================================================ Native select ============================================================ */ .sc-native-select { align-items: center; display: inline-flex; position: relative; width: 100%; } .sc-native-select__field { appearance: none; -webkit-appearance: none; backdrop-filter: blur(12px) saturate(120%); background: rgba(2, 8, 7, 0.62); border: var(--sc-border); border-radius: var(--sc-radius-2); box-shadow: var(--sc-shadow-inset); color: var(--sc-color-text); cursor: pointer; font-family: var(--sc-font-sans); font-size: var(--sc-font-size-3); height: 2.5rem; padding: 0 2rem 0 var(--sc-space-3); width: 100%; } .sc-native-select__icon { color: var(--sc-color-text-muted); pointer-events: none; position: absolute; right: var(--sc-space-3); } .sc-native-select__field option { background: var(--sc-color-background-soft); color: var(--sc-color-text); } /* ============================================================ Radio group ============================================================ */ .sc-radio-group { display: grid; gap: var(--sc-space-2); } .sc-radio { align-items: flex-start; cursor: pointer; display: inline-flex; gap: var(--sc-space-2); } .sc-radio__input { height: 1px; opacity: 0; position: absolute; width: 1px; } .sc-radio__dot { background: rgba(2, 8, 7, 0.68); border: var(--sc-border); border-radius: 50%; box-shadow: var(--sc-shadow-inset); flex: 0 0 auto; height: 1.1rem; margin-top: 1px; position: relative; width: 1.1rem; } .sc-radio__dot::after { background: var(--sc-color-accent-green); border-radius: 50%; content: ""; height: 0.5rem; inset: 0; margin: auto; opacity: 0; position: absolute; transform: scale(0.4); transition: opacity var(--sc-motion-duration-fast) var(--sc-motion-ease), transform var(--sc-motion-duration-fast) var(--sc-motion-ease); width: 0.5rem; } .sc-radio__input:checked + .sc-radio__dot { border-color: var(--sc-color-border-strong); } .sc-radio__input:checked + .sc-radio__dot::after { opacity: 1; transform: scale(1); } .sc-radio__input:focus-visible + .sc-radio__dot { outline: 2px solid var(--sc-color-focus); outline-offset: 2px; } .sc-radio__copy { display: grid; gap: 2px; } .sc-radio__label { color: var(--sc-color-text); font-size: var(--sc-font-size-3); } .sc-radio__description { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); } /* ============================================================ Slider ============================================================ */ .sc-slider { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; height: 1.25rem; width: 100%; } .sc-slider::-webkit-slider-runnable-track { background: rgb(var(--sc-theme-rgb-panel) / 0.14); border: var(--sc-border); border-radius: var(--sc-radius-round); height: 0.34rem; } .sc-slider::-moz-range-track { background: rgb(var(--sc-theme-rgb-panel) / 0.14); border: var(--sc-border); border-radius: var(--sc-radius-round); height: 0.34rem; } .sc-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; background: var(--sc-color-accent-green); border: 1px solid #06100c; border-radius: 50%; box-shadow: 0 0 0 3px rgb(var(--sc-theme-rgb-primary) / 0.2); height: 1rem; margin-top: -0.36rem; width: 1rem; } .sc-slider::-moz-range-thumb { background: var(--sc-color-accent-green); border: 1px solid #06100c; border-radius: 50%; height: 1rem; width: 1rem; } .sc-slider--blue::-webkit-slider-thumb { background: var(--sc-color-accent-blue); } .sc-slider--blue::-moz-range-thumb { background: var(--sc-color-accent-blue); } .sc-slider--warning::-webkit-slider-thumb { background: var(--sc-color-warning); } .sc-slider--warning::-moz-range-thumb { background: var(--sc-color-warning); } .sc-slider:focus-visible { outline: none; } .sc-slider:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px var(--sc-color-focus); } /* ============================================================ Toggle / Toggle group / Button group ============================================================ */ .sc-toggle { align-items: center; background: rgb(var(--sc-theme-rgb-panel) / 0.04); border: var(--sc-border); border-radius: var(--sc-radius-1); color: var(--sc-color-text-muted); cursor: pointer; display: inline-flex; font: inherit; font-size: var(--sc-font-size-2); gap: var(--sc-space-2); justify-content: center; min-height: 2rem; padding: 0 var(--sc-space-3); transition: background var(--sc-motion-duration-fast) var(--sc-motion-ease), border-color var(--sc-motion-duration-fast) var(--sc-motion-ease), color var(--sc-motion-duration-fast) var(--sc-motion-ease); } .sc-toggle--sm { min-height: 1.75rem; padding: 0 var(--sc-space-2); } .sc-toggle--lg { min-height: 2.5rem; padding: 0 var(--sc-space-4); } .sc-toggle--outline { background: transparent; } .sc-toggle:hover { background: rgb(var(--sc-theme-rgb-warm) / 0.1); border-color: rgb(var(--sc-theme-rgb-warm) / 0.5); color: var(--sc-color-warning); } .sc-toggle[data-state="on"] { background: rgb(var(--sc-theme-rgb-primary) / 0.16); border-color: var(--sc-color-border-strong); color: var(--sc-color-text); } .sc-toggle-group { display: inline-flex; flex-wrap: wrap; gap: var(--sc-space-1); } .sc-button-group { display: inline-flex; } .sc-button-group--vertical { flex-direction: column; } .sc-button-group .sc-button { border-radius: 0; } .sc-button-group--horizontal .sc-button:not(:first-child) { margin-left: -1px; } .sc-button-group--horizontal .sc-button:first-child { border-bottom-left-radius: var(--sc-radius-1); border-top-left-radius: var(--sc-radius-1); } .sc-button-group--horizontal .sc-button:last-child { border-bottom-right-radius: var(--sc-radius-1); border-top-right-radius: var(--sc-radius-1); } .sc-button-group--vertical .sc-button:not(:first-child) { margin-top: -1px; } .sc-button-group--vertical .sc-button:first-child { border-top-left-radius: var(--sc-radius-1); border-top-right-radius: var(--sc-radius-1); } .sc-button-group--vertical .sc-button:last-child { border-bottom-left-radius: var(--sc-radius-1); border-bottom-right-radius: var(--sc-radius-1); } .sc-button-group .sc-button:hover { position: relative; z-index: 1; } /* ============================================================ Breadcrumb ============================================================ */ .sc-breadcrumb__list { align-items: center; display: flex; flex-wrap: wrap; gap: var(--sc-space-2); list-style: none; margin: 0; padding: 0; } .sc-breadcrumb__item { align-items: center; display: inline-flex; } .sc-breadcrumb__link { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); text-decoration: none; } .sc-breadcrumb__link:hover { color: var(--sc-color-warning); } .sc-breadcrumb__page { color: var(--sc-color-text); font-size: var(--sc-font-size-2); font-weight: var(--sc-font-weight-medium); } .sc-breadcrumb__separator { align-items: center; color: var(--sc-color-text-subtle); display: inline-flex; } /* ============================================================ Pagination ============================================================ */ .sc-pagination__content { align-items: center; display: flex; flex-wrap: wrap; gap: var(--sc-space-1); list-style: none; margin: 0; padding: 0; } .sc-pagination__link, .sc-pagination__edge { align-items: center; background: rgb(var(--sc-theme-rgb-panel) / 0.04); border: var(--sc-border); border-radius: var(--sc-radius-1); color: var(--sc-color-text-muted); cursor: pointer; display: inline-flex; font-size: var(--sc-font-size-2); gap: 4px; height: 2rem; justify-content: center; min-width: 2rem; padding: 0 var(--sc-space-2); text-decoration: none; } .sc-pagination__link:hover, .sc-pagination__edge:hover { background: rgb(var(--sc-theme-rgb-warm) / 0.1); border-color: rgb(var(--sc-theme-rgb-warm) / 0.5); color: var(--sc-color-warning); } .sc-pagination__link--active { background: rgb(var(--sc-theme-rgb-primary) / 0.14); border-color: var(--sc-color-border-strong); color: var(--sc-color-text); } .sc-pagination__ellipsis { align-items: center; color: var(--sc-color-text-subtle); display: inline-flex; height: 2rem; justify-content: center; min-width: 2rem; } /* ============================================================ Table ============================================================ */ .sc-table-wrap { background: rgb(var(--sc-theme-rgb-panel) / 0.025); border: var(--sc-border); border-radius: var(--sc-radius-2); overflow-x: auto; width: 100%; } .sc-table { border-collapse: collapse; font-size: var(--sc-font-size-2); width: 100%; } .sc-table__caption { caption-side: bottom; color: var(--sc-color-text-subtle); font-size: var(--sc-font-size-2); padding: var(--sc-space-2) var(--sc-space-3); text-align: left; } .sc-table__head { border-bottom: var(--sc-border); color: var(--sc-color-text-subtle); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); font-weight: var(--sc-font-weight-medium); letter-spacing: 0.08em; padding: var(--sc-space-3); text-align: left; text-transform: uppercase; } .sc-table__cell { border-bottom: var(--sc-border); color: var(--sc-color-text-muted); padding: var(--sc-space-3); } .sc-table__body .sc-table__row:last-child .sc-table__cell { border-bottom: 0; } .sc-table__row:hover .sc-table__cell { background: rgb(var(--sc-theme-rgb-warm) / 0.08); color: var(--sc-color-text); } .sc-table__footer .sc-table__cell { color: var(--sc-color-text); font-weight: var(--sc-font-weight-medium); } /* ============================================================ Scroll area ============================================================ */ .sc-scroll-area { background: rgb(var(--sc-theme-rgb-panel) / 0.03); border: var(--sc-border); border-radius: var(--sc-radius-2); scrollbar-color: rgb(var(--sc-theme-rgb-primary) / 0.4) transparent; scrollbar-width: thin; } .sc-scroll-area--vertical { overflow-x: hidden; overflow-y: auto; } .sc-scroll-area--horizontal { overflow-x: auto; overflow-y: hidden; } .sc-scroll-area--both { overflow: auto; } .sc-scroll-area::-webkit-scrollbar { height: 8px; width: 8px; } .sc-scroll-area::-webkit-scrollbar-thumb { background: rgb(var(--sc-theme-rgb-primary) / 0.3); background-clip: padding-box; border: 2px solid transparent; border-radius: var(--sc-radius-round); } .sc-scroll-area::-webkit-scrollbar-thumb:hover { background: rgb(var(--sc-theme-rgb-warm) / 0.55); background-clip: padding-box; } .sc-scroll-area::-webkit-scrollbar-track { background: transparent; } .sc-toggle:focus-visible, .sc-native-select__field:focus-visible, .sc-pagination__link:focus-visible, .sc-pagination__edge:focus-visible, .sc-breadcrumb__link:focus-visible, .sc-slider:focus-visible { outline: 2px solid var(--sc-color-focus); outline-offset: 2px; } /* ============================================================ Duotone image + card ============================================================ */ .sc-duotone { aspect-ratio: 4 / 5; background: var(--sc-duotone-shadow, var(--sc-color-background)); border-radius: var(--sc-radius-2); isolation: isolate; overflow: hidden; position: relative; } .sc-duotone__image { display: block; filter: grayscale(1) contrast(1.18) brightness(0.92); height: 100%; object-fit: cover; width: 100%; } .sc-duotone__tint { background: var(--sc-duotone-color, rgb(var(--sc-theme-rgb-primary))); inset: 0; mix-blend-mode: multiply; opacity: var(--sc-duotone-intensity, 0.82); pointer-events: none; position: absolute; } .sc-duotone__grain { background: radial-gradient(circle at 50% 28%, transparent 38%, rgba(0, 0, 0, 0.5)); inset: 0; mix-blend-mode: multiply; pointer-events: none; position: absolute; } .sc-duotone--scanlines::after { background: repeating-linear-gradient( 0deg, rgba(0, 0, 0, 0.16) 0, rgba(0, 0, 0, 0.16) 1px, transparent 1px, transparent 3px ); content: ""; inset: 0; mix-blend-mode: multiply; opacity: 0.5; pointer-events: none; position: absolute; } .sc-duotone-card { border: var(--sc-border-strong); border-radius: var(--sc-radius-2); display: flex; min-height: 22rem; overflow: hidden; position: relative; } .sc-duotone-card__media { aspect-ratio: auto; border-radius: 0; inset: 0; position: absolute; } .sc-duotone-card__scrim { background: linear-gradient( 180deg, rgba(3, 8, 7, 0.16) 0%, rgba(3, 8, 7, 0.5) 54%, rgba(2, 6, 5, 0.93) 100% ); inset: 0; pointer-events: none; position: absolute; } .sc-duotone-card__content { display: flex; flex-direction: column; gap: var(--sc-space-3); padding: var(--sc-space-4); position: relative; width: 100%; z-index: 1; } .sc-duotone-card--featured { border-color: var(--sc-color-accent-green); box-shadow: 0 0 0 1px var(--sc-color-accent-green-soft), var(--sc-shadow-2); } .sc-duotone-card__header { display: grid; gap: var(--sc-space-1); } .sc-duotone-card__title { font-size: var(--sc-font-size-6); line-height: var(--sc-line-height-tight); margin: 0; } .sc-duotone-card__meta { color: var(--sc-color-text-muted); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); margin: 0; } .sc-duotone-card__body { color: var(--sc-color-text-muted); display: grid; font-size: var(--sc-font-size-2); gap: var(--sc-space-1); margin-top: auto; } .sc-duotone-card__footer { display: flex; flex-wrap: wrap; gap: var(--sc-space-2); margin-top: var(--sc-space-3); } /* ============================================================ Live / animated surfaces (canvas) ============================================================ */ .sc-live { background: rgba(3, 8, 7, 0.55); border: var(--sc-border); border-radius: var(--sc-radius-2); box-shadow: var(--sc-shadow-inset); min-height: 14rem; overflow: hidden; position: relative; } .sc-live__canvas { display: block; height: 100%; inset: 0; position: absolute; width: 100%; } .sc-live__label { color: var(--sc-color-accent-green); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); left: var(--sc-space-3); letter-spacing: 0.12em; position: absolute; text-transform: uppercase; top: var(--sc-space-3); z-index: 1; } .sc-radar-sweep { background: radial-gradient(circle at 50% 50%, rgb(var(--sc-theme-rgb-primary) / 0.08), transparent 60%), rgba(3, 8, 7, 0.62); } /* ============================================================ Glitch text ============================================================ */ .sc-glitch { display: inline-block; font-family: var(--sc-font-mono); font-weight: var(--sc-font-weight-bold); position: relative; } .sc-glitch__base { color: var(--sc-color-text); position: relative; z-index: 2; } .sc-glitch::before, .sc-glitch::after { content: attr(data-text); inset: 0; pointer-events: none; position: absolute; z-index: 1; } .sc-glitch::before { animation: sc-glitch-a 3.6s steps(2, jump-none) infinite; color: var(--sc-color-accent-green); } .sc-glitch::after { animation: sc-glitch-b 4.3s steps(2, jump-none) infinite; color: var(--sc-color-accent-blue); } @keyframes sc-glitch-a { 0%, 100% { clip-path: inset(0 0 85% 0); transform: translate(-1px, -1px); } 20% { clip-path: inset(42% 0 40% 0); transform: translate(1px, 1px); } 40% { clip-path: inset(70% 0 10% 0); transform: translate(-1px, 0); } 60% { clip-path: inset(20% 0 60% 0); transform: translate(1px, -1px); } 80% { clip-path: inset(55% 0 25% 0); transform: translate(-1px, 1px); } } @keyframes sc-glitch-b { 0%, 100% { clip-path: inset(70% 0 10% 0); transform: translate(1px, 1px); } 25% { clip-path: inset(10% 0 75% 0); transform: translate(-1px, 0); } 50% { clip-path: inset(45% 0 35% 0); transform: translate(1px, -1px); } 75% { clip-path: inset(80% 0 5% 0); transform: translate(-1px, 1px); } } /* ============================================================ Orbit loader ============================================================ */ .sc-orbit { display: inline-block; position: relative; } .sc-orbit--sm { height: 1.6rem; width: 1.6rem; } .sc-orbit--md { height: 2.4rem; width: 2.4rem; } .sc-orbit--lg { height: 3.2rem; width: 3.2rem; } .sc-orbit__ring { animation: sc-spin linear infinite; border: 1px solid rgb(var(--sc-theme-rgb-primary) / 0.18); border-radius: 50%; inset: 0; position: absolute; } .sc-orbit__ring--1 { animation-duration: 2.1s; } .sc-orbit__ring--2 { animation-direction: reverse; animation-duration: 3.1s; inset: 18%; } .sc-orbit__ring--3 { animation-duration: 2.5s; inset: 36%; } .sc-orbit__dot { background: var(--sc-color-accent-green); border-radius: 50%; height: 0.32rem; left: 50%; position: absolute; top: -0.16rem; transform: translateX(-50%); width: 0.32rem; } .sc-orbit__ring--2 .sc-orbit__dot { background: var(--sc-color-accent-blue); } .sc-orbit__ring--3 .sc-orbit__dot { background: var(--sc-color-warning); } .sc-orbit__core { background: var(--sc-color-accent-green); border-radius: 50%; box-shadow: 0 0 8px rgb(var(--sc-theme-rgb-primary) / 0.7); height: 0.3rem; inset: 0; margin: auto; position: absolute; width: 0.3rem; } @media (prefers-reduced-motion: reduce) { .sc-spinner__ring, .sc-orbit__ring, .sc-glitch::before, .sc-glitch::after { animation: none; } } /* ============================================================ Logo row (static backers / press strip) ============================================================ */ .sc-logo-row { font-family: var(--sc-font-sans); text-align: center; } .sc-logo-row__heading { color: var(--sc-color-text-subtle); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); letter-spacing: 0.18em; margin: 0 0 var(--sc-space-4); text-transform: uppercase; } .sc-logo-row__items { align-items: center; display: inline-flex; flex-wrap: wrap; gap: var(--sc-space-6); justify-content: center; } .sc-logo-row__items img { filter: invert(1); height: 1.5rem; opacity: 0.7; width: auto; } .sc-logo-row__text { color: var(--sc-color-text-muted); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-3); font-weight: var(--sc-font-weight-bold); letter-spacing: 0.04em; white-space: nowrap; } /* ============================================================ Community badge variants ============================================================ */ .sc-community-badge { transition: border-color var(--sc-motion-duration-fast) var(--sc-motion-ease), transform var(--sc-motion-duration-fast) var(--sc-motion-ease); } .sc-community-badge:hover { border-color: rgb(var(--sc-theme-rgb-warm) / 0.55); transform: translateY(-2px); } .sc-community-badge__trailing { align-items: center; color: var(--sc-color-text-muted); display: inline-flex; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-2); margin-left: auto; padding-left: var(--sc-space-3); } .sc-community-badge--sm { gap: var(--sc-space-2); min-height: 3.5rem; padding: var(--sc-space-2) var(--sc-space-3); } .sc-community-badge--sm .sc-community-badge__icon { height: 1.75rem; width: 1.75rem; } .sc-community-badge--sm .sc-community-badge__title { font-size: var(--sc-font-size-3); } .sc-community-badge--outline { background: transparent; } .sc-community-badge--ghost { background: transparent; border-color: transparent; box-shadow: none; } .sc-community-badge--ghost:hover { background: rgb(var(--sc-theme-rgb-warm) / 0.08); } .sc-community-badge--solid { background: rgb(var(--sc-theme-rgb-primary) / 0.14); border-color: var(--sc-color-border-strong); } .sc-community-badge--blue .sc-community-badge__icon { background: var(--sc-color-accent-blue-soft); color: var(--sc-color-accent-blue); } .sc-community-badge--blue:hover { border-color: var(--sc-color-accent-blue); } .sc-community-badge--blue.sc-community-badge--solid { background: var(--sc-color-accent-blue-soft); } .sc-community-badge--warm .sc-community-badge__icon { background: var(--sc-color-warning-soft); color: var(--sc-color-warning); } .sc-community-badge--warm:hover { border-color: var(--sc-color-warning); } .sc-community-badge--warm.sc-community-badge--solid { background: var(--sc-color-warning-soft); } .sc-community-badge--neutral .sc-community-badge__icon { background: rgb(var(--sc-theme-rgb-panel) / 0.08); color: var(--sc-color-text-muted); } /* ============================================================ Popover open animation (entrance) ============================================================ */ .sc-popover-content { transform-origin: var(--radix-popover-content-transform-origin); } .sc-popover-content[data-state="open"] { animation: sc-popover-in 200ms var(--sc-motion-ease); } @keyframes sc-popover-in { 0% { opacity: 0; transform: translateY(8px) scale(0.96); } 100% { opacity: 1; transform: translateY(0) scale(1); } } @media (prefers-reduced-motion: reduce) { .sc-popover-content[data-state="open"] { animation: none; } } /* ============================================================ Loader (frame-based terminal animations) ============================================================ */ .sc-loader { align-items: center; color: var(--sc-color-accent-green); display: inline-flex; font-family: var(--sc-font-mono); justify-content: center; line-height: 1; vertical-align: middle; } .sc-loader__frame { display: inline-block; min-width: 1ch; text-align: center; white-space: pre; } .sc-loader--sm { font-size: var(--sc-font-size-2); } .sc-loader--md { font-size: var(--sc-font-size-4); } .sc-loader--lg { font-size: var(--sc-font-size-6); } .sc-button__loader { align-items: center; display: inline-flex; } .sc-button__loader .sc-loader { color: currentColor; } /* ============================================================ Tool call indicator (agent loading pill) ============================================================ */ .sc-tool-call { align-items: center; background: rgb(var(--sc-theme-rgb-primary) / 0.08); border: var(--sc-border); border-radius: var(--sc-radius-round); color: var(--sc-color-text-muted); display: inline-flex; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); gap: var(--sc-space-2); padding: 3px var(--sc-space-2) 3px 5px; vertical-align: middle; } .sc-tool-call__icon { align-items: center; color: var(--sc-color-accent-green); display: inline-flex; } .sc-tool-call__icon .sc-spinner__ring { border-width: 2px; height: 0.72rem; width: 0.72rem; } .sc-tool-call__verb { letter-spacing: 0.06em; text-transform: uppercase; } .sc-tool-call__name { background: rgb(var(--sc-theme-rgb-panel) / 0.1); border-radius: var(--sc-radius-1); color: var(--sc-color-text); font-size: var(--sc-font-size-1); padding: 0 5px; } .sc-tool-call__dots { align-items: flex-end; display: inline-flex; gap: 2px; padding-bottom: 2px; } .sc-tool-call__dots i { animation: sc-tool-dot 1.4s ease-in-out infinite; background: currentColor; border-radius: 50%; height: 3px; opacity: 0.3; width: 3px; } .sc-tool-call__dots i:nth-child(2) { animation-delay: 0.16s; } .sc-tool-call__dots i:nth-child(3) { animation-delay: 0.32s; } .sc-tool-call--done { color: var(--sc-color-text-subtle); } .sc-tool-call--error { background: var(--sc-color-danger-soft); color: var(--sc-color-danger); } .sc-tool-call--error .sc-tool-call__icon { color: var(--sc-color-danger); } @keyframes sc-tool-dot { 0%, 100% { opacity: 0.25; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-2px); } } @media (prefers-reduced-motion: reduce) { .sc-tool-call__dots i { animation: none; } } /* ============================================================ CRT old-TV scanlines layered onto core surfaces ============================================================ */ .sc-card, .sc-glass-panel, .sc-glass-card, .sc-mock-console, .sc-mock-ide, .sc-dialog-content, .sc-popover-content, .sc-toast-root, .sc-pricing-card, .sc-prompt-hero, .sc-chat-dock, .sc-prompt-box, .sc-sticky-banner, .sc-community-badge, .sc-before-after { background-image: repeating-linear-gradient( 0deg, rgb(var(--sc-theme-rgb-text) / 0.055) 0, rgb(var(--sc-theme-rgb-text) / 0.055) 1px, transparent 1px, transparent 3px ); } /* ============================================================ Calendar / DatePicker / DateInput ============================================================ */ .sc-calendar { background: var(--sc-color-surface-raised); border: var(--sc-border); border-radius: var(--sc-radius-3); box-shadow: var(--sc-shadow-1), var(--sc-shadow-inset); color: var(--sc-color-text); display: grid; font-family: var(--sc-font-sans); gap: var(--sc-space-3); padding: var(--sc-space-4); width: max-content; } .sc-calendar__header { align-items: center; display: flex; justify-content: space-between; } .sc-calendar__title { font-size: var(--sc-font-size-3); font-weight: var(--sc-font-weight-medium); text-transform: capitalize; } .sc-calendar__nav { align-items: center; background: rgb(var(--sc-theme-rgb-panel) / 0.05); border: var(--sc-border); border-radius: var(--sc-radius-1); color: var(--sc-color-text-muted); cursor: pointer; display: inline-flex; height: 1.85rem; justify-content: center; width: 1.85rem; } .sc-calendar__nav:hover { background: rgb(var(--sc-theme-rgb-warm) / 0.12); border-color: rgb(var(--sc-theme-rgb-warm) / 0.5); color: var(--sc-color-warning); } .sc-calendar__nav:focus-visible, .sc-calendar__day:focus-visible { outline: 2px solid var(--sc-color-focus); outline-offset: 1px; } .sc-calendar__weekdays, .sc-calendar__week { display: grid; grid-template-columns: repeat(7, 1fr); } .sc-calendar__weekday { color: var(--sc-color-text-subtle); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); padding-bottom: var(--sc-space-1); text-align: center; text-transform: uppercase; } .sc-calendar__grid { display: grid; gap: 2px; } .sc-calendar__cell { display: flex; justify-content: center; } .sc-calendar__day { align-items: center; background: transparent; border: 1px solid transparent; border-radius: var(--sc-radius-1); color: var(--sc-color-text); cursor: pointer; display: inline-flex; font: inherit; font-size: var(--sc-font-size-2); height: 2rem; justify-content: center; width: 2rem; } .sc-calendar__day:hover:not(:disabled) { background: rgb(var(--sc-theme-rgb-warm) / 0.14); border-color: rgb(var(--sc-theme-rgb-warm) / 0.5); color: var(--sc-color-warning); } .sc-calendar__day--outside { color: var(--sc-color-text-subtle); opacity: 0.55; } .sc-calendar__day--today { border-color: var(--sc-color-border-strong); font-weight: var(--sc-font-weight-bold); } .sc-calendar__day--in-range { background: rgb(var(--sc-theme-rgb-primary) / 0.13); border-radius: 0; } .sc-calendar__day--selected, .sc-calendar__day--range-start, .sc-calendar__day--range-end { background: var(--sc-color-accent-green); border-color: var(--sc-color-accent-green); color: #07120f; font-weight: var(--sc-font-weight-medium); } .sc-calendar__day--selected:hover:not(:disabled), .sc-calendar__day--range-start:hover:not(:disabled), .sc-calendar__day--range-end:hover:not(:disabled) { background: color-mix(in srgb, var(--sc-color-accent-green), white 12%); border-color: var(--sc-color-accent-green); color: #07120f; } .sc-calendar__day:disabled { cursor: not-allowed; opacity: 0.32; } /* DatePicker */ .sc-datepicker__trigger { gap: var(--sc-space-2); justify-content: flex-start; min-width: 13rem; } .sc-datepicker__trigger--empty { color: var(--sc-color-text-subtle); } .sc-datepicker__popover { padding: 0; } .sc-datepicker__popover .sc-calendar { background: transparent; border: 0; box-shadow: none; padding: var(--sc-space-1); } /* DateInput (styled native date field) */ .sc-date-input { align-items: center; height: 2.5rem; padding: 0 var(--sc-space-3); } .sc-date-input::-webkit-calendar-picker-indicator { cursor: pointer; filter: invert(1) brightness(1.4); opacity: 0.65; } .sc-date-input::-webkit-calendar-picker-indicator:hover { opacity: 1; } /* ============================================================ Gen X Soft Club page sections: gradient banner, header, footer, announcement bar. Film-graded, theme-blended, drifting gradients (muted greens / greys / tans / cold blue). ============================================================ */ @keyframes sc-gradient-drift { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } } @keyframes sc-gradient-flow { to { background-position: 200% 0; } } @keyframes sc-soft-sheen { 0% { background-position: 150% 0; } 100% { background-position: -50% 0; } } /* Film-graded gradient presets (shared by banner + announcement bar) */ .sc-gradient-banner--transit, .sc-announcement-bar--transit { --sc-grad: linear-gradient(115deg, rgb(var(--sc-theme-rgb-secondary) / 0.55) 0%, #16222b 26%, #1f3330 52%, rgb(var(--sc-theme-rgb-primary) / 0.32) 74%, #14201d 100%); } .sc-gradient-banner--crossproc, .sc-announcement-bar--crossproc { --sc-grad: linear-gradient(125deg, rgb(var(--sc-theme-rgb-secondary) / 0.5) 0%, #16221f 30%, rgb(var(--sc-theme-rgb-warm) / 0.4) 60%, #261d18 82%, #131a18 100%); } .sc-gradient-banner--smoke, .sc-announcement-bar--smoke { --sc-grad: linear-gradient(110deg, #191d1b, #2c322f, #20231f, #33322d, #1a1d1b); } .sc-gradient-banner--terrain, .sc-announcement-bar--terrain { --sc-grad: linear-gradient(115deg, rgb(var(--sc-theme-rgb-primary) / 0.3) 0%, #232318 30%, #3a3429 58%, rgb(var(--sc-theme-rgb-warm) / 0.32) 82%, #1c1a14 100%); } .sc-gradient-banner--dusk, .sc-announcement-bar--dusk { --sc-grad: linear-gradient(125deg, #15132a 0%, rgb(var(--sc-theme-rgb-secondary) / 0.42) 28%, #2a1d2e 54%, rgb(var(--sc-theme-rgb-warm) / 0.3) 80%, #130f12 100%); } .sc-gradient-banner--bleach { --sc-grad: linear-gradient(120deg, #262b27 0%, rgb(var(--sc-theme-rgb-secondary) / 0.3) 30%, #3a3f39 55%, rgb(var(--sc-theme-rgb-primary) / 0.22) 80%, #20231f 100%); } /* Gradient banner */ .sc-gradient-banner { animation: sc-gradient-drift 22s ease-in-out infinite; background-color: var(--sc-color-background); background-image: var(--sc-grad); background-size: 220% 220%; border: var(--sc-border-strong); border-radius: var(--sc-radius-3); box-shadow: var(--sc-shadow-2), var(--sc-shadow-inset); color: var(--sc-color-text); font-family: var(--sc-font-sans); isolation: isolate; overflow: hidden; position: relative; } .sc-gradient-banner--static { animation: none; } .sc-gradient-banner__sheen { animation: sc-soft-sheen 9s ease-in-out infinite; background: linear-gradient(105deg, transparent 32%, rgba(255, 255, 255, 0.08) 48%, transparent 62%); background-size: 280% 100%; inset: 0; pointer-events: none; position: absolute; z-index: 0; } .sc-gradient-banner__grain { background: repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.16) 0, rgba(0, 0, 0, 0.16) 1px, transparent 1px, transparent 3px), radial-gradient(rgba(255, 255, 255, 0.05) 0.5px, transparent 0.6px) 0 0 / 3px 3px; inset: 0; mix-blend-mode: overlay; opacity: 0.5; pointer-events: none; position: absolute; z-index: 0; } .sc-gradient-banner__content { display: grid; gap: var(--sc-space-3); justify-items: start; position: relative; z-index: 1; } .sc-gradient-banner--sm .sc-gradient-banner__content { padding: var(--sc-space-5); } .sc-gradient-banner--md .sc-gradient-banner__content { padding: var(--sc-space-8) var(--sc-space-6); } .sc-gradient-banner--lg .sc-gradient-banner__content { min-height: 16rem; padding: var(--sc-space-10) var(--sc-space-8); } .sc-gradient-banner__eyebrow { color: rgb(var(--sc-theme-rgb-text) / 0.85); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); letter-spacing: 0.16em; text-shadow: 0 1px 10px rgba(2, 8, 7, 0.7); text-transform: uppercase; } .sc-gradient-banner__title { font-size: clamp(1.6rem, 3.6vw, 2.8rem); line-height: var(--sc-line-height-tight); margin: 0; max-width: 22ch; text-shadow: 0 2px 22px rgba(2, 8, 7, 0.78); text-wrap: balance; } .sc-gradient-banner__text { color: rgb(var(--sc-theme-rgb-text) / 0.82); font-size: var(--sc-font-size-3); line-height: var(--sc-line-height-normal); margin: 0; max-width: 46ch; text-shadow: 0 1px 14px rgba(2, 8, 7, 0.8); } .sc-gradient-banner__actions { display: flex; flex-wrap: wrap; gap: var(--sc-space-2); margin-top: var(--sc-space-2); } /* Soft header */ .sc-soft-header { backdrop-filter: blur(18px) saturate(122%); background: var(--sc-color-overlay); border: var(--sc-border); border-radius: var(--sc-radius-3); color: var(--sc-color-text); font-family: var(--sc-font-sans); overflow: hidden; position: relative; } .sc-soft-header--sticky { position: sticky; top: 0; z-index: 40; } .sc-soft-header__inner { align-items: center; display: flex; flex-wrap: wrap; gap: var(--sc-space-4); justify-content: space-between; padding: var(--sc-space-3) var(--sc-space-5); } .sc-soft-header__rule { animation: sc-gradient-flow 14s linear infinite; background: linear-gradient( 90deg, transparent, rgb(var(--sc-theme-rgb-secondary) / 0.6), rgb(var(--sc-theme-rgb-primary) / 0.7), rgb(var(--sc-theme-rgb-warm) / 0.5), transparent ); background-size: 200% 100%; bottom: 0; height: 1px; left: 0; position: absolute; right: 0; } .sc-soft-header__brand { display: grid; line-height: 1.05; text-decoration: none; white-space: nowrap; } .sc-soft-header__brand strong { color: var(--sc-color-accent-green); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); letter-spacing: 0.12em; } .sc-soft-header__brand span { font-size: var(--sc-font-size-4); font-weight: var(--sc-font-weight-bold); } .sc-soft-header__nav { align-items: center; display: flex; flex-wrap: wrap; gap: var(--sc-space-1); } .sc-soft-header__link { border-radius: var(--sc-radius-1); color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); padding: var(--sc-space-2) var(--sc-space-3); text-decoration: none; } .sc-soft-header__link:hover { background: rgb(var(--sc-theme-rgb-warm) / 0.12); color: var(--sc-color-warning); } .sc-soft-header__actions { align-items: center; display: flex; flex-wrap: wrap; gap: var(--sc-space-2); } /* Soft footer */ .sc-soft-footer { border-top: var(--sc-border); color: var(--sc-color-text); font-family: var(--sc-font-sans); position: relative; } .sc-soft-footer__rule { animation: sc-gradient-flow 16s linear infinite; background: linear-gradient( 90deg, transparent, rgb(var(--sc-theme-rgb-secondary) / 0.6), rgb(var(--sc-theme-rgb-primary) / 0.7), rgb(var(--sc-theme-rgb-warm) / 0.5), transparent ); background-size: 200% 100%; height: 1px; left: 0; position: absolute; right: 0; top: -1px; } .sc-soft-footer__inner { display: grid; gap: var(--sc-space-8); grid-template-columns: minmax(0, 1.3fr) minmax(0, 2fr); margin: 0 auto; max-width: 72rem; padding: var(--sc-space-8) var(--sc-space-5); width: 100%; } .sc-soft-footer__brand { align-content: start; display: grid; gap: var(--sc-space-2); } .sc-soft-footer__brand strong { color: var(--sc-color-accent-green); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); letter-spacing: 0.12em; } .sc-soft-footer__brand > span { font-size: var(--sc-font-size-5); font-weight: var(--sc-font-weight-bold); } .sc-soft-footer__brand p { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); line-height: var(--sc-line-height-normal); margin: 0; max-width: 34ch; } .sc-soft-footer__columns { display: grid; gap: var(--sc-space-6); grid-template-columns: repeat(3, minmax(0, 1fr)); } .sc-soft-footer__column { align-content: start; display: grid; gap: var(--sc-space-2); } .sc-soft-footer__heading { color: var(--sc-color-text-subtle); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); letter-spacing: 0.12em; text-transform: uppercase; } .sc-soft-footer__link { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); text-decoration: none; } .sc-soft-footer__link:hover { color: var(--sc-color-warning); } .sc-soft-footer__base { align-items: center; border-top: var(--sc-border); color: var(--sc-color-text-subtle); display: flex; flex-wrap: wrap; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); gap: var(--sc-space-3); justify-content: space-between; margin: 0 auto; max-width: 72rem; padding: var(--sc-space-4) var(--sc-space-5); width: 100%; } /* Announcement bar */ .sc-announcement-bar { align-items: center; animation: sc-gradient-drift 26s ease-in-out infinite; background-color: var(--sc-color-background-soft); background-image: var(--sc-grad); background-size: 220% 220%; border: var(--sc-border); border-radius: var(--sc-radius-2); color: var(--sc-color-text); display: flex; font-family: var(--sc-font-sans); gap: var(--sc-space-2); isolation: isolate; overflow: hidden; padding: var(--sc-space-2) var(--sc-space-3); position: relative; } .sc-announcement-bar__sweep { animation: sc-soft-sheen 11s ease-in-out infinite; background: linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, 0.09) 50%, transparent 65%); background-size: 280% 100%; inset: 0; pointer-events: none; position: absolute; z-index: 0; } .sc-announcement-bar__content { align-items: center; display: flex; flex: 1; flex-wrap: wrap; gap: var(--sc-space-2); min-width: 0; position: relative; z-index: 1; } .sc-announcement-bar__label { background: rgb(var(--sc-theme-rgb-text) / 0.14); border-radius: var(--sc-radius-1); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); letter-spacing: 0.08em; padding: 2px 6px; text-transform: uppercase; } .sc-announcement-bar__message { font-size: var(--sc-font-size-2); text-shadow: 0 1px 8px rgba(2, 8, 7, 0.6); } .sc-announcement-bar__action { margin-left: auto; } .sc-announcement-bar__dismiss { align-items: center; background: transparent; border: 0; border-radius: var(--sc-radius-1); color: var(--sc-color-text-muted); cursor: pointer; display: inline-flex; flex: 0 0 auto; height: 1.6rem; justify-content: center; position: relative; width: 1.6rem; z-index: 1; } .sc-announcement-bar__dismiss:hover { background: rgb(var(--sc-theme-rgb-warm) / 0.14); color: var(--sc-color-warning); } .sc-soft-header__link:focus-visible, .sc-soft-footer__link:focus-visible, .sc-announcement-bar__dismiss:focus-visible { outline: 2px solid var(--sc-color-focus); outline-offset: 2px; } @media (max-width: 720px) { .sc-soft-footer__inner { gap: var(--sc-space-6); grid-template-columns: 1fr; } .sc-soft-footer__columns { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (prefers-reduced-motion: reduce) { .sc-gradient-banner, .sc-announcement-bar, .sc-gradient-banner__sheen, .sc-announcement-bar__sweep, .sc-soft-header__rule, .sc-soft-footer__rule { animation: none; } } /* ============================================================ Overlays: Sheet, Drawer, AlertDialog, HoverCard ============================================================ */ .sc-sheet-overlay, .sc-drawer-overlay, .sc-alert-dialog-overlay { backdrop-filter: blur(6px); background: rgba(0, 4, 3, 0.6); inset: 0; position: fixed; z-index: 50; } .sc-sheet-content { background: var(--sc-color-surface-raised); border: var(--sc-border-strong); box-shadow: var(--sc-shadow-2), var(--sc-shadow-inset); color: var(--sc-color-text); display: flex; flex-direction: column; font-family: var(--sc-font-sans); gap: var(--sc-space-4); overflow-y: auto; padding: var(--sc-space-5); position: fixed; z-index: 51; } .sc-sheet-content--right { bottom: 0; right: 0; top: 0; width: min(24rem, 92vw); } .sc-sheet-content--left { bottom: 0; left: 0; top: 0; width: min(24rem, 92vw); } .sc-sheet-content--top { left: 0; max-height: 85vh; right: 0; top: 0; } .sc-sheet-content--bottom { bottom: 0; left: 0; max-height: 85vh; right: 0; } .sc-sheet-content--right[data-state="open"] { animation: sc-sheet-right 220ms var(--sc-motion-ease); } .sc-sheet-content--left[data-state="open"] { animation: sc-sheet-left 220ms var(--sc-motion-ease); } .sc-sheet-content--top[data-state="open"] { animation: sc-sheet-top 220ms var(--sc-motion-ease); } .sc-sheet-content--bottom[data-state="open"] { animation: sc-sheet-bottom 220ms var(--sc-motion-ease); } @keyframes sc-sheet-right { from { transform: translateX(100%); } } @keyframes sc-sheet-left { from { transform: translateX(-100%); } } @keyframes sc-sheet-top { from { transform: translateY(-100%); } } @keyframes sc-sheet-bottom { from { transform: translateY(100%); } } .sc-sheet-close { position: absolute; right: var(--sc-space-3); top: var(--sc-space-3); } .sc-sheet-header, .sc-drawer-header, .sc-alert-dialog-header { display: grid; gap: var(--sc-space-1); } .sc-sheet-title, .sc-drawer-title, .sc-alert-dialog-title { font-size: var(--sc-font-size-5); line-height: var(--sc-line-height-tight); margin: 0; } .sc-sheet-description, .sc-drawer-description, .sc-alert-dialog-description { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-3); line-height: var(--sc-line-height-normal); margin: 0; } .sc-sheet-footer, .sc-drawer-footer { display: flex; flex-wrap: wrap; gap: var(--sc-space-2); margin-top: auto; } /* Drawer (bottom) */ .sc-drawer-content { background: var(--sc-color-surface-raised); border: var(--sc-border-strong); border-bottom: 0; border-radius: var(--sc-radius-3) var(--sc-radius-3) 0 0; bottom: 0; box-shadow: var(--sc-shadow-2), var(--sc-shadow-inset); color: var(--sc-color-text); display: flex; flex-direction: column; font-family: var(--sc-font-sans); gap: var(--sc-space-4); left: 0; margin: 0 auto; max-height: 88vh; max-width: 32rem; padding: var(--sc-space-3) var(--sc-space-5) var(--sc-space-5); position: fixed; right: 0; z-index: 51; } .sc-drawer-content[data-state="open"] { animation: sc-sheet-bottom 240ms var(--sc-motion-ease); } .sc-drawer-handle { background: var(--sc-color-border-strong); border-radius: var(--sc-radius-round); height: 4px; margin: 0 auto var(--sc-space-2); width: 2.5rem; } /* Alert dialog */ .sc-alert-dialog-content { background: var(--sc-color-surface-raised); border: var(--sc-border-strong); border-radius: var(--sc-radius-3); box-shadow: var(--sc-shadow-2), var(--sc-shadow-inset); color: var(--sc-color-text); display: grid; font-family: var(--sc-font-sans); gap: var(--sc-space-4); left: 50%; max-width: min(calc(100vw - 2rem), 26rem); padding: var(--sc-space-5); position: fixed; top: 50%; transform: translate(-50%, -50%); width: 100%; z-index: 51; } .sc-alert-dialog-content[data-state="open"] { animation: sc-alert-dialog-in 200ms var(--sc-motion-ease); } @keyframes sc-alert-dialog-in { from { opacity: 0; transform: translate(-50%, -46%) scale(0.96); } } .sc-alert-dialog-footer { display: flex; flex-wrap: wrap; gap: var(--sc-space-2); justify-content: flex-end; } /* Hover card */ .sc-hover-card { display: inline-flex; position: relative; } .sc-hover-card__trigger:focus-visible { outline: 2px solid var(--sc-color-focus); outline-offset: 2px; } .sc-hover-card__content { backdrop-filter: blur(18px) saturate(130%); background: var(--sc-color-surface-raised); border: var(--sc-border-strong); border-radius: var(--sc-radius-3); box-shadow: var(--sc-shadow-2), var(--sc-shadow-inset); color: var(--sc-color-text); font-size: var(--sc-font-size-2); line-height: var(--sc-line-height-normal); min-width: 14rem; opacity: 0; padding: var(--sc-space-3); pointer-events: none; position: absolute; transform: translateY(4px); transition: opacity 160ms var(--sc-motion-ease), transform 160ms var(--sc-motion-ease); visibility: hidden; width: max-content; z-index: 40; } .sc-hover-card__content--bottom { top: calc(100% + 8px); } .sc-hover-card__content--top { bottom: calc(100% + 8px); transform: translateY(-4px); } .sc-hover-card__content--start { left: 0; } .sc-hover-card__content--center { left: 50%; transform: translateX(-50%) translateY(4px); } .sc-hover-card__content--end { right: 0; } .sc-hover-card:hover .sc-hover-card__content, .sc-hover-card:focus-within .sc-hover-card__content { opacity: 1; pointer-events: auto; transform: translateY(0); visibility: visible; } .sc-hover-card:hover .sc-hover-card__content--center, .sc-hover-card:focus-within .sc-hover-card__content--center { transform: translateX(-50%) translateY(0); } @media (prefers-reduced-motion: reduce) { .sc-sheet-content[data-state="open"], .sc-drawer-content[data-state="open"], .sc-alert-dialog-content[data-state="open"] { animation: none; } } /* ============================================================ Data & Flow: Stepper, Timeline, Rating, NumberInput ============================================================ */ /* Stepper */ .sc-stepper { display: flex; font-family: var(--sc-font-sans); list-style: none; margin: 0; padding: 0; } .sc-stepper--horizontal { align-items: flex-start; flex-direction: row; gap: 0; } .sc-stepper--vertical { flex-direction: column; gap: 0; } .sc-stepper__step { align-items: center; display: flex; gap: var(--sc-space-2); position: relative; } .sc-stepper--horizontal .sc-stepper__step { flex: 1 1 0; min-width: 0; } .sc-stepper--horizontal .sc-stepper__step:last-child { flex: 0 0 auto; } .sc-stepper--vertical .sc-stepper__step { align-items: flex-start; padding-bottom: var(--sc-space-5); } .sc-stepper--vertical .sc-stepper__step:last-child { padding-bottom: 0; } .sc-stepper__marker { align-items: center; background: var(--sc-color-surface-raised); border: 1px solid var(--sc-color-border-strong); border-radius: var(--sc-radius-round); color: var(--sc-color-text-muted); display: inline-flex; flex: 0 0 auto; font-size: var(--sc-font-size-2); font-variant-numeric: tabular-nums; height: 1.75rem; justify-content: center; transition: background 180ms var(--sc-motion-ease), border-color 180ms var(--sc-motion-ease), box-shadow 180ms var(--sc-motion-ease), color 180ms var(--sc-motion-ease); width: 1.75rem; } .sc-stepper__body { display: grid; gap: 2px; min-width: 0; } .sc-stepper__label { color: var(--sc-color-text); font-size: var(--sc-font-size-3); font-weight: 600; line-height: var(--sc-line-height-tight); } .sc-stepper__description { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); line-height: var(--sc-line-height-normal); } .sc-stepper__line { background: var(--sc-color-border); flex: 1 1 auto; } .sc-stepper--horizontal .sc-stepper__line { height: 2px; margin: 0 var(--sc-space-2); min-width: var(--sc-space-4); } .sc-stepper--vertical .sc-stepper__line { bottom: 0; left: 0.8125rem; position: absolute; top: 1.75rem; width: 2px; } .sc-stepper__step--complete .sc-stepper__marker { background: rgb(var(--sc-theme-rgb-primary) / 0.16); border-color: rgb(var(--sc-theme-rgb-primary) / 0.5); color: var(--sc-color-text); } .sc-stepper__step--complete .sc-stepper__line { background: rgb(var(--sc-theme-rgb-primary) / 0.45); } .sc-stepper__step--current .sc-stepper__marker { background: rgb(var(--sc-theme-rgb-primary) / 0.2); border-color: rgb(var(--sc-theme-rgb-primary) / 0.7); box-shadow: 0 0 0 4px rgb(var(--sc-theme-rgb-primary) / 0.14); color: var(--sc-color-text); } /* Timeline */ .sc-timeline { display: flex; flex-direction: column; font-family: var(--sc-font-sans); list-style: none; margin: 0; padding: 0; } .sc-timeline__item { padding-bottom: var(--sc-space-5); padding-left: var(--sc-space-5); position: relative; } .sc-timeline__item:last-child { padding-bottom: 0; } .sc-timeline__item::before { background: var(--sc-color-border); bottom: 0; content: ""; left: 0.3125rem; position: absolute; top: 0.9rem; width: 2px; } .sc-timeline__item:last-child::before { display: none; } .sc-timeline__node { background: var(--sc-color-surface-raised); border: 2px solid var(--sc-color-border-strong); border-radius: var(--sc-radius-round); height: 0.75rem; left: 0; position: absolute; top: 0.35rem; width: 0.75rem; } .sc-timeline__item--done .sc-timeline__node { background: rgb(var(--sc-theme-rgb-primary) / 0.85); border-color: rgb(var(--sc-theme-rgb-primary) / 0.85); } .sc-timeline__item--active .sc-timeline__node { background: rgb(var(--sc-theme-rgb-primary) / 0.95); border-color: rgb(var(--sc-theme-rgb-primary) / 0.95); box-shadow: 0 0 0 4px rgb(var(--sc-theme-rgb-primary) / 0.16); } .sc-timeline__content { display: grid; gap: 2px; } .sc-timeline__time { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-1); letter-spacing: 0.04em; text-transform: uppercase; } .sc-timeline__title { color: var(--sc-color-text); font-size: var(--sc-font-size-3); font-weight: 600; margin: 0; } .sc-timeline__description { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); line-height: var(--sc-line-height-normal); margin: 0; } /* Rating */ .sc-rating { align-items: center; display: inline-flex; gap: var(--sc-space-1); } .sc-rating__star { appearance: none; background: none; border: 0; color: var(--sc-color-border-strong); cursor: pointer; display: inline-flex; padding: 2px; transition: color 140ms var(--sc-motion-ease), transform 140ms var(--sc-motion-ease); } .sc-rating__star--active { color: rgb(var(--sc-theme-rgb-primary)); } .sc-rating:not(.sc-rating--readonly) .sc-rating__star:hover { transform: scale(1.12); } .sc-rating__star:focus-visible { border-radius: var(--sc-radius-1); outline: 2px solid var(--sc-color-focus); outline-offset: 2px; } .sc-rating--readonly .sc-rating__star { cursor: default; } /* NumberInput */ .sc-number-input { align-items: stretch; background: var(--sc-color-surface-raised); border: 1px solid var(--sc-color-border-strong); border-radius: var(--sc-radius-2); display: inline-flex; font-family: var(--sc-font-sans); overflow: hidden; transition: border-color 160ms var(--sc-motion-ease), box-shadow 160ms var(--sc-motion-ease); } .sc-number-input:focus-within { border-color: rgb(var(--sc-theme-rgb-primary) / 0.6); box-shadow: 0 0 0 3px rgb(var(--sc-theme-rgb-primary) / 0.16); } .sc-number-input__step { align-items: center; appearance: none; background: none; border: 0; color: var(--sc-color-text-muted); cursor: pointer; display: inline-flex; justify-content: center; padding: 0 var(--sc-space-3); transition: background 140ms var(--sc-motion-ease), color 140ms var(--sc-motion-ease); } .sc-number-input__step:hover:not(:disabled) { background: rgb(var(--sc-theme-rgb-warm) / 0.18); color: var(--sc-color-warning); } .sc-number-input__step:disabled { cursor: not-allowed; opacity: 0.4; } .sc-number-input__field { appearance: textfield; -moz-appearance: textfield; background: none; border: 0; border-left: 1px solid var(--sc-color-border); border-right: 1px solid var(--sc-color-border); color: var(--sc-color-text); font-size: var(--sc-font-size-3); font-variant-numeric: tabular-nums; outline: none; padding: var(--sc-space-2) var(--sc-space-3); text-align: center; width: 4rem; } .sc-number-input__field::-webkit-outer-spin-button, .sc-number-input__field::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .sc-number-input--disabled { opacity: 0.55; } @media (prefers-reduced-motion: reduce) { .sc-rating__star, .sc-stepper__marker, .sc-number-input, .sc-number-input__step { transition: none; } } /* ============================================================ Pickers: Combobox, Command palette ============================================================ */ /* Combobox */ .sc-combobox__trigger { display: inline-flex; gap: var(--sc-space-2); justify-content: space-between; min-width: 13rem; text-align: left; width: 100%; } .sc-combobox__value { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sc-combobox__value--placeholder { color: var(--sc-color-text-muted); } .sc-combobox__content.sc-popover-content { max-width: none; padding: 0; width: var(--radix-popover-trigger-width); } .sc-combobox__search { align-items: center; border-bottom: 1px solid var(--sc-color-border); color: var(--sc-color-text-muted); display: flex; gap: var(--sc-space-2); padding: var(--sc-space-2) var(--sc-space-3); } .sc-combobox__input { appearance: none; background: none; border: 0; color: var(--sc-color-text); flex: 1 1 auto; font-family: var(--sc-font-sans); font-size: var(--sc-font-size-3); outline: none; } .sc-combobox__input::placeholder { color: var(--sc-color-text-muted); } .sc-combobox__list { display: grid; gap: 2px; max-height: 16rem; overflow-y: auto; padding: var(--sc-space-2); } .sc-combobox__option { align-items: center; appearance: none; background: none; border: 0; border-radius: var(--sc-radius-2); color: var(--sc-color-text); cursor: pointer; display: flex; font-family: var(--sc-font-sans); font-size: var(--sc-font-size-3); gap: var(--sc-space-2); padding: var(--sc-space-2) var(--sc-space-2); text-align: left; transition: background 120ms var(--sc-motion-ease), color 120ms var(--sc-motion-ease); width: 100%; } .sc-combobox__option--active:not(:disabled) { background: rgb(var(--sc-theme-rgb-warm) / 0.18); color: var(--sc-color-warning); } .sc-combobox__option:disabled { cursor: not-allowed; opacity: 0.45; } .sc-combobox__check { flex: 0 0 auto; opacity: 0; transition: opacity 120ms var(--sc-motion-ease); } .sc-combobox__check--on { opacity: 1; } .sc-combobox__empty { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); margin: 0; padding: var(--sc-space-4); text-align: center; } /* Command palette */ .sc-command { background: var(--sc-color-surface-raised); border: var(--sc-border-strong); border-radius: var(--sc-radius-3); box-shadow: var(--sc-shadow-1); color: var(--sc-color-text); display: flex; flex-direction: column; font-family: var(--sc-font-sans); overflow: hidden; } .sc-command__search { align-items: center; border-bottom: 1px solid var(--sc-color-border); color: var(--sc-color-text-muted); display: flex; gap: var(--sc-space-2); padding: var(--sc-space-3) var(--sc-space-4); } .sc-command__input { appearance: none; background: none; border: 0; color: var(--sc-color-text); flex: 1 1 auto; font-family: var(--sc-font-sans); font-size: var(--sc-font-size-3); outline: none; } .sc-command__input::placeholder { color: var(--sc-color-text-muted); } .sc-command__list { display: flex; flex-direction: column; gap: 2px; max-height: 20rem; overflow-y: auto; padding: var(--sc-space-2); } .sc-command__group { display: flex; flex-direction: column; gap: 2px; } .sc-command__group:not(:has(.sc-command__item)) { display: none; } .sc-command__group-heading { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-1); letter-spacing: 0.06em; padding: var(--sc-space-2) var(--sc-space-2) var(--sc-space-1); text-transform: uppercase; } .sc-command__separator { background: var(--sc-color-border); height: 1px; margin: var(--sc-space-2) 0; } .sc-command__item { align-items: center; border-radius: var(--sc-radius-2); color: var(--sc-color-text); cursor: pointer; display: flex; font-size: var(--sc-font-size-3); gap: var(--sc-space-2); padding: var(--sc-space-2) var(--sc-space-2); transition: background 120ms var(--sc-motion-ease), color 120ms var(--sc-motion-ease); } .sc-command__item:hover, .sc-command__item:focus-visible { background: rgb(var(--sc-theme-rgb-warm) / 0.18); color: var(--sc-color-warning); outline: none; } .sc-command__empty { color: var(--sc-color-text-muted); display: none; font-size: var(--sc-font-size-2); margin: 0; padding: var(--sc-space-5) var(--sc-space-4); text-align: center; } .sc-command__list:not(:has(.sc-command__item)) .sc-command__empty { display: block; } .sc-command-dialog.sc-dialog-content { overflow: hidden; padding: 0; } .sc-command-dialog .sc-command { border: 0; box-shadow: none; } @media (prefers-reduced-motion: reduce) { .sc-combobox__option, .sc-combobox__check, .sc-command__item { transition: none; } } /* ============================================================ Carousel + Tree ============================================================ */ /* Carousel */ .sc-carousel { font-family: var(--sc-font-sans); position: relative; } .sc-carousel__viewport { background: var(--sc-color-surface-raised); border: var(--sc-border-strong); border-radius: var(--sc-radius-3); box-shadow: var(--sc-shadow-inset); overflow: hidden; } .sc-carousel__track { display: flex; transition: transform 360ms var(--sc-motion-ease); will-change: transform; } .sc-carousel__slide { flex: 0 0 100%; min-width: 0; } .sc-carousel__arrow { align-items: center; appearance: none; backdrop-filter: blur(6px); background: rgb(var(--sc-theme-rgb-panel) / 0.7); border: var(--sc-border-strong); border-radius: var(--sc-radius-round); color: var(--sc-color-text); cursor: pointer; display: inline-flex; height: 2rem; justify-content: center; position: absolute; top: 50%; transform: translateY(-50%); transition: background 140ms var(--sc-motion-ease), color 140ms var(--sc-motion-ease), opacity 140ms var(--sc-motion-ease); width: 2rem; } .sc-carousel__arrow--prev { left: var(--sc-space-3); } .sc-carousel__arrow--next { right: var(--sc-space-3); } .sc-carousel__arrow:hover:not(:disabled) { background: rgb(var(--sc-theme-rgb-warm) / 0.85); color: var(--sc-color-warning); } .sc-carousel__arrow:disabled { cursor: not-allowed; opacity: 0.35; } .sc-carousel__dots { bottom: var(--sc-space-3); display: flex; gap: var(--sc-space-2); left: 50%; position: absolute; transform: translateX(-50%); } .sc-carousel__dot { appearance: none; background: rgb(var(--sc-theme-rgb-text) / 0.35); border: 0; border-radius: var(--sc-radius-round); cursor: pointer; height: 0.5rem; padding: 0; transition: background 140ms var(--sc-motion-ease), width 140ms var(--sc-motion-ease); width: 0.5rem; } .sc-carousel__dot--active { background: rgb(var(--sc-theme-rgb-primary)); width: 1.25rem; } /* Tree */ .sc-tree, .sc-tree__group { font-family: var(--sc-font-sans); list-style: none; margin: 0; padding: 0; } .sc-tree__row { align-items: center; border-radius: var(--sc-radius-2); color: var(--sc-color-text); cursor: pointer; display: flex; gap: var(--sc-space-1); padding-bottom: var(--sc-space-1); padding-right: var(--sc-space-2); padding-top: var(--sc-space-1); transition: background 120ms var(--sc-motion-ease), color 120ms var(--sc-motion-ease); } .sc-tree__row:hover { background: rgb(var(--sc-theme-rgb-warm) / 0.16); color: var(--sc-color-warning); } .sc-tree__row:focus-visible { outline: 2px solid var(--sc-color-focus); outline-offset: -2px; } .sc-tree__row--selected { background: rgb(var(--sc-theme-rgb-primary) / 0.18); color: var(--sc-color-text); } .sc-tree__row--selected:hover { background: rgb(var(--sc-theme-rgb-primary) / 0.24); color: var(--sc-color-text); } .sc-tree__chevron { color: var(--sc-color-text-muted); flex: 0 0 auto; transition: transform 140ms var(--sc-motion-ease); } .sc-tree__chevron--open { transform: rotate(90deg); } .sc-tree__chevron--hidden { visibility: hidden; } .sc-tree__label { font-size: var(--sc-font-size-3); line-height: var(--sc-line-height-tight); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @media (prefers-reduced-motion: reduce) { .sc-carousel__track, .sc-carousel__arrow, .sc-carousel__dot, .sc-tree__row, .sc-tree__chevron { transition: none; } } /* ============================================================ Menus: Menubar, ContextMenu, NavigationMenu ============================================================ */ /* Shared menu popup behaviour (reuses .sc-dropdown-* surface + item styles) */ .sc-menu-content { transform-origin: var( --radix-context-menu-content-transform-origin, var(--radix-menubar-content-transform-origin) ); } .sc-menu-content[data-state="open"] { animation: sc-popover-in 200ms var(--sc-motion-ease); } .sc-menu-subtrigger { justify-content: space-between; } .sc-menu-subtrigger[data-state="open"] { background: rgb(var(--sc-theme-rgb-warm) / 0.16); color: var(--sc-color-warning); } .sc-menu-subtrigger__icon { margin-left: var(--sc-space-3); } .sc-menu-shortcut { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); letter-spacing: 0.08em; margin-left: auto; padding-left: var(--sc-space-4); } /* Menubar */ .sc-menubar { align-items: center; background: var(--sc-color-surface-raised); border: var(--sc-border-strong); border-radius: var(--sc-radius-2); box-shadow: var(--sc-shadow-inset); display: inline-flex; font-family: var(--sc-font-sans); gap: 2px; padding: var(--sc-space-1); } .sc-menubar-trigger { appearance: none; background: none; border: 0; border-radius: var(--sc-radius-1); color: var(--sc-color-text); cursor: pointer; font-family: inherit; font-size: var(--sc-font-size-3); outline: none; padding: var(--sc-space-1) var(--sc-space-3); transition: background 140ms var(--sc-motion-ease), color 140ms var(--sc-motion-ease); } .sc-menubar-trigger[data-highlighted], .sc-menubar-trigger[data-state="open"] { background: rgb(var(--sc-theme-rgb-warm) / 0.16); color: var(--sc-color-warning); } .sc-menubar-trigger:focus-visible { outline: 2px solid var(--sc-color-focus); outline-offset: -2px; } /* Navigation menu */ .sc-nav-menu { display: flex; font-family: var(--sc-font-sans); justify-content: center; position: relative; z-index: 1; } .sc-nav-menu__list { align-items: center; display: flex; gap: var(--sc-space-1); list-style: none; margin: 0; padding: 0; } .sc-nav-menu__trigger, .sc-nav-menu__link { align-items: center; appearance: none; background: none; border: 0; border-radius: var(--sc-radius-2); color: var(--sc-color-text); cursor: pointer; display: inline-flex; font-family: inherit; font-size: var(--sc-font-size-3); gap: var(--sc-space-1); outline: none; padding: var(--sc-space-2) var(--sc-space-3); text-decoration: none; transition: background 140ms var(--sc-motion-ease), color 140ms var(--sc-motion-ease); } .sc-nav-menu__trigger:hover, .sc-nav-menu__trigger[data-state="open"], .sc-nav-menu__link:hover { background: rgb(var(--sc-theme-rgb-warm) / 0.16); color: var(--sc-color-warning); } .sc-nav-menu__trigger:focus-visible, .sc-nav-menu__link:focus-visible { outline: 2px solid var(--sc-color-focus); outline-offset: -2px; } .sc-nav-menu__trigger-icon { transition: transform 180ms var(--sc-motion-ease); } .sc-nav-menu__trigger[data-state="open"] .sc-nav-menu__trigger-icon { transform: rotate(180deg); } .sc-nav-menu__content { display: grid; gap: 2px; left: 0; min-width: 16rem; padding: var(--sc-space-2); position: absolute; top: 0; width: max-content; } .sc-nav-menu__viewport-wrap { display: flex; justify-content: center; left: 0; position: absolute; top: 100%; width: 100%; } .sc-nav-menu__viewport { background: var(--sc-color-surface-raised); border: var(--sc-border-strong); border-radius: var(--sc-radius-3); box-shadow: var(--sc-shadow-2), var(--sc-shadow-inset); height: var(--radix-navigation-menu-viewport-height); margin-top: var(--sc-space-2); overflow: hidden; position: relative; transform-origin: top center; transition: height 240ms var(--sc-motion-ease), width 240ms var(--sc-motion-ease); width: var(--radix-navigation-menu-viewport-width); } .sc-nav-menu__viewport[data-state="open"] { animation: sc-popover-in 200ms var(--sc-motion-ease); } @media (prefers-reduced-motion: reduce) { .sc-menubar-trigger, .sc-nav-menu__trigger, .sc-nav-menu__link, .sc-nav-menu__trigger-icon, .sc-menu-content[data-state="open"], .sc-nav-menu__viewport, .sc-nav-menu__viewport[data-state="open"] { animation: none; transition: none; } } /* ============================================================ Primitives: Collapsible, AspectRatio, InputOTP ============================================================ */ /* Collapsible */ .sc-collapsible { font-family: var(--sc-font-sans); } .sc-collapsible__trigger { align-items: center; appearance: none; background: var(--sc-color-surface-raised); border: var(--sc-border-strong); border-radius: var(--sc-radius-2); color: var(--sc-color-text); cursor: pointer; display: flex; font-family: inherit; font-size: var(--sc-font-size-3); gap: var(--sc-space-2); justify-content: space-between; padding: var(--sc-space-2) var(--sc-space-3); transition: background 140ms var(--sc-motion-ease), color 140ms var(--sc-motion-ease); width: 100%; } .sc-collapsible__trigger:hover { background: rgb(var(--sc-theme-rgb-warm) / 0.16); color: var(--sc-color-warning); } .sc-collapsible__trigger:focus-visible { outline: 2px solid var(--sc-color-focus); outline-offset: 2px; } /* Height toggles instantly (always collapses correctly); the inner fades and slides so the change still reads as motion without a janky height tween. */ .sc-collapsible__content { height: 0; overflow: hidden; } .sc-collapsible__content[data-state="open"] { height: auto; } .sc-collapsible__inner { min-height: 0; opacity: 0; transform: translateY(-4px); transition: opacity 200ms var(--sc-motion-ease), transform 200ms var(--sc-motion-ease); } .sc-collapsible__content[data-state="open"] .sc-collapsible__inner { opacity: 1; transform: translateY(0); } /* AspectRatio */ .sc-aspect-ratio { overflow: hidden; position: relative; width: 100%; } .sc-aspect-ratio > * { height: 100%; width: 100%; } .sc-aspect-ratio > img, .sc-aspect-ratio > video { display: block; object-fit: cover; } /* InputOTP */ .sc-input-otp { display: inline-flex; position: relative; } .sc-input-otp__input { appearance: none; background: transparent; border: 0; caret-color: transparent; color: transparent; cursor: pointer; font-size: 1rem; inset: 0; outline: none; position: absolute; width: 100%; z-index: 2; } .sc-input-otp__slots { display: flex; gap: var(--sc-space-2); pointer-events: none; } .sc-input-otp__slot { align-items: center; background: var(--sc-color-surface-raised); border: var(--sc-border-strong); border-radius: var(--sc-radius-2); box-shadow: var(--sc-shadow-inset); color: var(--sc-color-text); display: flex; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-5); font-variant-numeric: tabular-nums; height: 3rem; justify-content: center; position: relative; transition: border-color 140ms var(--sc-motion-ease), box-shadow 140ms var(--sc-motion-ease); width: 2.5rem; } .sc-input-otp__slot--active { border-color: rgb(var(--sc-theme-rgb-primary) / 0.7); box-shadow: var(--sc-shadow-inset), 0 0 0 3px rgb(var(--sc-theme-rgb-primary) / 0.16); } .sc-input-otp__caret { animation: sc-otp-blink 1.1s steps(2, start) infinite; background: rgb(var(--sc-theme-rgb-primary)); border-radius: 1px; height: 1.4rem; position: absolute; width: 2px; } @keyframes sc-otp-blink { 50% { opacity: 0; } } .sc-input-otp--disabled { opacity: 0.55; } .sc-input-otp--disabled .sc-input-otp__input { cursor: not-allowed; } @media (prefers-reduced-motion: reduce) { .sc-collapsible__inner, .sc-input-otp__slot { transition: none; } .sc-input-otp__caret { animation: none; } } /* ============================================================ Instruments: control-room cards for generative canvases ============================================================ */ .sc-instrument { background: var(--sc-color-surface-raised); border: var(--sc-border-strong); border-radius: var(--sc-radius-3); box-shadow: var(--sc-shadow-inset); display: flex; flex-direction: column; font-family: var(--sc-font-sans); gap: var(--sc-space-3); padding: var(--sc-space-3); transition: border-color 220ms var(--sc-motion-ease), box-shadow 220ms var(--sc-motion-ease); } .sc-instrument--focused { border-color: rgb(var(--sc-theme-rgb-primary) / 0.6); box-shadow: var(--sc-shadow-inset), 0 0 0 1px rgb(var(--sc-theme-rgb-primary) / 0.35), 0 0 36px rgb(var(--sc-theme-rgb-primary) / 0.14); } .sc-instrument__viewport { position: relative; } .sc-instrument__viewport .sc-live { min-height: 15rem; transition: min-height 320ms var(--sc-motion-ease); } .sc-instrument--focused .sc-instrument__viewport .sc-live { min-height: 22rem; } .sc-instrument__header { align-items: flex-start; display: flex; gap: var(--sc-space-3); justify-content: space-between; } .sc-instrument__heading { display: grid; gap: 2px; min-width: 0; } .sc-instrument__title { font-size: var(--sc-font-size-4); line-height: var(--sc-line-height-tight); margin: 0; } .sc-instrument__meta { color: var(--sc-color-text-muted); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); letter-spacing: 0.08em; text-transform: uppercase; } .sc-instrument__tag { color: rgb(var(--sc-theme-rgb-primary)); flex: 0 0 auto; font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); letter-spacing: 0.1em; text-transform: uppercase; } .sc-instrument__description { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); line-height: var(--sc-line-height-normal); margin: 0; } .sc-instrument__chips { display: flex; flex-wrap: wrap; gap: var(--sc-space-1); list-style: none; margin: 0; padding: 0; } .sc-instrument__chip { border: 1px solid var(--sc-color-border-strong); border-radius: var(--sc-radius-1); color: var(--sc-color-text-muted); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); letter-spacing: 0.06em; padding: 1px var(--sc-space-2); text-transform: uppercase; white-space: nowrap; } .sc-instrument__params { color: rgb(var(--sc-theme-rgb-primary) / 0.85); font-family: var(--sc-font-mono); font-size: var(--sc-font-size-1); letter-spacing: 0.05em; margin: 0; } .sc-instrument__actions { display: flex; flex-wrap: wrap; gap: var(--sc-space-2); } @media (prefers-reduced-motion: reduce) { .sc-instrument, .sc-instrument__viewport .sc-live { transition: none; } } /* ============================================================ Image effects (canvas): ASCII, mosaic, dither, halftone Each is a .sc-live surface with a signature ground the transparent canvas prints over. Grounds read the active theme. ============================================================ */ .sc-ascii-image, .sc-mosaic-image, .sc-dither-image, .sc-halftone-image { isolation: isolate; } .sc-ascii-image { background: radial-gradient(120% 90% at 50% 36%, rgb(var(--sc-theme-rgb-secondary) / 0.16), transparent 60%), radial-gradient(150% 130% at 50% 120%, rgb(var(--sc-theme-rgb-primary) / 0.1), transparent 55%), #05060a; } .sc-mosaic-image { background: linear-gradient(125deg, rgb(var(--sc-theme-rgb-secondary) / 0.22), transparent 46%), linear-gradient(305deg, rgb(var(--sc-theme-rgb-primary) / 0.2), transparent 48%), #0a0d0c; } .sc-dither-image { background: linear-gradient( 158deg, rgb(var(--sc-theme-rgb-secondary) / 0.94), rgb(var(--sc-theme-rgb-primary) / 0.68) 72% ), #0a1430; } .sc-halftone-image { background: radial-gradient(120% 100% at 50% 0%, rgb(var(--sc-theme-rgb-primary) / 0.18), transparent 58%), linear-gradient(180deg, rgba(8, 16, 12, 0.45), #04070a 78%); } /* ============================================================ ASCII snapshot — framed shareable card ============================================================ */ .sc-ascii-snapshot { backdrop-filter: blur(18px) saturate(126%); background: var(--sc-color-surface); border: var(--sc-border); border-radius: var(--sc-radius-3); box-shadow: var(--sc-shadow-1), var(--sc-shadow-inset); color: var(--sc-color-text); display: flex; flex-direction: column; font-family: var(--sc-font-sans); gap: var(--sc-space-4); padding: var(--sc-space-5); position: relative; } .sc-ascii-snapshot__frame { inset: var(--sc-space-3); pointer-events: none; position: absolute; z-index: 2; } .sc-ascii-snapshot__corner { border: 0 solid rgb(var(--sc-theme-rgb-primary) / 0.5); height: 14px; position: absolute; width: 14px; } .sc-ascii-snapshot__corner--tl { border-left-width: 1.5px; border-top-width: 1.5px; left: 0; top: 0; } .sc-ascii-snapshot__corner--tr { border-right-width: 1.5px; border-top-width: 1.5px; right: 0; top: 0; } .sc-ascii-snapshot__corner--bl { border-bottom-width: 1.5px; border-left-width: 1.5px; bottom: 0; left: 0; } .sc-ascii-snapshot__corner--br { border-bottom-width: 1.5px; border-right-width: 1.5px; bottom: 0; right: 0; } .sc-ascii-snapshot__media { display: flex; min-height: 16rem; position: relative; } .sc-ascii-snapshot__media > * { flex: 1; min-height: 0; } .sc-ascii-snapshot__media .sc-live { min-height: 0; } .sc-ascii-snapshot__body { display: grid; gap: var(--sc-space-2); } .sc-ascii-snapshot__headline { align-items: flex-start; display: flex; gap: var(--sc-space-3); justify-content: space-between; } .sc-ascii-snapshot__title { font-size: var(--sc-font-size-6); line-height: var(--sc-line-height-tight); margin: 0; } .sc-ascii-snapshot__action { flex: 0 0 auto; } .sc-ascii-snapshot__icon { align-items: center; background: var(--sc-color-surface-inset); border: var(--sc-border-strong); border-radius: var(--sc-radius-2); color: var(--sc-color-text); cursor: pointer; display: inline-flex; height: 2rem; justify-content: center; transition: background 160ms var(--sc-motion-ease), border-color 160ms var(--sc-motion-ease), transform 160ms var(--sc-motion-ease); width: 2rem; } .sc-ascii-snapshot__icon:hover { background: var(--sc-color-surface-raised); border-color: rgb(var(--sc-theme-rgb-primary) / 0.6); transform: translateY(-1px); } .sc-ascii-snapshot__icon:focus-visible { outline: 2px solid rgb(var(--sc-theme-rgb-primary) / 0.6); outline-offset: 2px; } .sc-ascii-snapshot__description { color: var(--sc-color-text-muted); font-size: var(--sc-font-size-2); line-height: var(--sc-line-height-normal); margin: 0; max-width: 40ch; } .sc-ascii-snapshot__footer { align-items: center; border-top: var(--sc-border); display: flex; flex-wrap: wrap; gap: var(--sc-space-3); justify-content: space-between; margin-top: auto; padding-top: var(--sc-space-4); } .sc-ascii-snapshot__actions { display: flex; flex-wrap: wrap; gap: var(--sc-space-2); } @media (prefers-reduced-motion: reduce) { .sc-ascii-snapshot__icon { transition: none; } } ``` ## Utilities ### Soft Club Base (`base`) Design tokens, the shared Soft Club stylesheet (all four themes), and the cx class helper. Every Soft Club component depends on this item. - Registry item: https://cobanov.github.io/soft-club-ui/r/base.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/base.json` - npm dependencies: clsx@^2.1.1 - Registry dependencies: none - Installs to: `lib/soft-club/cx.ts` ```ts import { clsx, type ClassValue } from "clsx"; export function cx(...inputs: ClassValue[]) { return clsx(inputs); } ``` ### ThemeColor (`theme-color`) Reads a Soft Club `--sc-theme-rgb-*` token off an element's computed style and returns it as an `[r, g, b]` triple. Lets canvas surfaces follow the active theme, which CSS variables alone cannot do inside a 2D context. - Registry item: https://cobanov.github.io/soft-club-ui/r/theme-color.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/theme-color.json` - npm dependencies: none - Registry dependencies: base - Installs to: `lib/soft-club/theme-color.ts` ```ts export type Rgb = [number, number, number]; /** * Reads a Soft Club `--sc-theme-rgb-*` token off an element's computed style and * returns it as an `[r, g, b]` triple. Lets canvas surfaces follow the active * theme, which CSS variables alone cannot do inside a 2D context. */ export function themeRgb(host: HTMLElement | null, token: string, fallback: Rgb): Rgb { if (!host || typeof window === "undefined") return fallback; const raw = window.getComputedStyle(host).getPropertyValue(token).trim(); if (!raw) return fallback; const parts = raw.split(/[\s,]+/).map(Number); if (parts.length >= 3 && parts.slice(0, 3).every((value) => !Number.isNaN(value))) { return [parts[0], parts[1], parts[2]]; } return fallback; } export const rgba = (color: Rgb, alpha = 1) => `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${alpha})`; ``` ## Hooks ### UseAsciiField (`use-ascii-field`) Soft Club UseAsciiField hook. - Registry item: https://cobanov.github.io/soft-club-ui/r/use-ascii-field.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/use-ascii-field.json` - npm dependencies: none - Registry dependencies: base - Installs to: `hooks/soft-club/use-ascii-field.ts` ```ts "use client"; import * as React from "react"; export interface UseAsciiFieldOptions { baseOpacity?: number; charRamp?: string; cols?: number; colorful?: boolean; fontFamily?: string; fontSize?: number; frameMs?: number; palette?: string[]; reactive?: boolean; rippleRadius?: number; rippleStrength?: number; rows?: number; spotlightOpacity?: number; spotlightRadius?: number; } const defaultRamp = " .`'\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"; const defaultPalette = ["#8effad", "#8bb8d7", "#ff8a3d", "#f7fff8"]; export function useAsciiField( canvasRef: React.RefObject, hostRef: React.RefObject, options: UseAsciiFieldOptions = {} ) { const { baseOpacity = 0.48, charRamp = defaultRamp, cols: colsOpt, colorful = true, fontFamily = "var(--sc-font-mono)", fontSize = 11, frameMs = 60, palette: paletteOpt, reactive = true, rippleRadius = 6, rippleStrength = 1.05, rows: rowsOpt, spotlightOpacity = 0.92, spotlightRadius = 9 } = options; const palette = React.useMemo( () => paletteOpt ?? (colorful ? defaultPalette : null), [colorful, paletteOpt] ); React.useEffect(() => { const canvas = canvasRef.current; const host = hostRef.current; if (!canvas || !host) return; const ctx = canvas.getContext("2d"); if (!ctx) return; let animationFrame = 0; let lastFrame = 0; let cols = 0; let rows = 0; let cellWidth = 0; let cellHeight = 0; let field = new Float32Array(0); const pointer = { x: -9999, y: -9999 }; const seed = () => { field = new Float32Array(cols * rows); for (let y = 0; y < rows; y += 1) { for (let x = 0; x < cols; x += 1) { const nx = (x / Math.max(1, cols)) * 2 - 1; const ny = (y / Math.max(1, rows)) * 2 - 1; const distance = Math.sqrt(nx * nx + ny * ny); const sweep = 0.5 + 0.5 * Math.sin(nx * 6 + ny * 2); const center = 1 - Math.min(1, distance * 1.25); field[y * cols + x] = 0.28 * sweep + 0.54 * center; } } }; const resize = () => { const rect = host.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.font = `${fontSize}px ${fontFamily}`; ctx.textBaseline = "top"; const measured = ctx.measureText("M").width || fontSize * 0.62; cellWidth = measured; cellHeight = fontSize * 1.18; cols = colsOpt ?? Math.max(1, Math.floor(rect.width / cellWidth)); rows = rowsOpt ?? Math.max(1, Math.floor(rect.height / cellHeight)); if (colsOpt !== undefined) cellWidth = rect.width / cols; if (rowsOpt !== undefined) cellHeight = rect.height / rows; seed(); }; const render = (timeStamp: number) => { if (timeStamp - lastFrame < frameMs) { animationFrame = requestAnimationFrame(render); return; } lastFrame = timeStamp; if (cols === 0 || rows === 0) resize(); const rect = canvas.getBoundingClientRect(); const time = timeStamp * 0.001; const pointerCol = (pointer.x - rect.left) / cellWidth; const pointerRow = (pointer.y - rect.top) / cellHeight; const pointerMargin = 24; const pointerInside = pointer.x >= rect.left - pointerMargin && pointer.x <= rect.right + pointerMargin && pointer.y >= rect.top - pointerMargin && pointer.y <= rect.bottom + pointerMargin; ctx.clearRect(0, 0, rect.width, rect.height); const rampMax = charRamp.length - 1; const spotRadiusSquared = spotlightRadius * spotlightRadius * 2; for (let y = 0; y < rows; y += 1) { for (let x = 0; x < cols; x += 1) { const base = field[y * cols + x] ?? 0; const wave = 0.14 * Math.sin(x * 0.18 + time * 1.4) * Math.cos(y * 0.22 - time * 1.1); const dx = x - pointerCol; const dy = (y - pointerRow) * 1.8; const distanceSquared = dx * dx + dy * dy; const distance = Math.sqrt(distanceSquared); const ripple = reactive && pointerInside ? rippleStrength * Math.exp(-distanceSquared / 80) - 0.52 * Math.exp(-((distance - rippleRadius) ** 2) / 30) : 0; const value = Math.max(0, Math.min(1, base + wave + ripple)); const character = charRamp[Math.floor(value * rampMax)]; if (character === " ") continue; let alpha = baseOpacity; if (reactive && pointerInside) { const spotlight = Math.exp(-distanceSquared / spotRadiusSquared); alpha = baseOpacity + (spotlightOpacity - baseOpacity) * spotlight; } if (alpha <= 0.01) continue; const paletteIndex = Math.floor(Math.abs(x * 0.1 + y * 0.07 + time * 0.12)); ctx.globalAlpha = Math.max(0, Math.min(1, alpha)); ctx.fillStyle = palette?.[paletteIndex % palette.length] ?? "#e8ffee"; ctx.fillText(character, x * cellWidth, y * cellHeight); } } ctx.globalAlpha = 1; animationFrame = requestAnimationFrame(render); }; const onPointerMove = (event: MouseEvent) => { pointer.x = event.clientX; pointer.y = event.clientY; }; const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host); resize(); if (reactive) window.addEventListener("mousemove", onPointerMove, { passive: true }); animationFrame = requestAnimationFrame(render); return () => { cancelAnimationFrame(animationFrame); resizeObserver.disconnect(); if (reactive) window.removeEventListener("mousemove", onPointerMove); }; }, [ baseOpacity, canvasRef, charRamp, colsOpt, fontFamily, fontSize, frameMs, hostRef, palette, reactive, rippleRadius, rippleStrength, rowsOpt, spotlightOpacity, spotlightRadius ]); } ``` ### UseCanvasAnimation (`use-canvas-animation`) Re-runs the driver when this value changes. Use it to force a fresh static frame once an async data source (e.g. an image) has loaded, so the reduced-motion single-frame path does not get stuck on a placeholder. - Registry item: https://cobanov.github.io/soft-club-ui/r/use-canvas-animation.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/use-canvas-animation.json` - npm dependencies: none - Registry dependencies: base - Installs to: `hooks/soft-club/use-canvas-animation.ts` ```ts "use client"; import * as React from "react"; export interface CanvasFrame { ctx: CanvasRenderingContext2D; width: number; height: number; time: number; dt: number; pointer: { x: number; y: number; inside: boolean }; } export interface UseCanvasAnimationOptions { fps?: number; reactive?: boolean; /** * Re-runs the driver when this value changes. Use it to force a fresh static * frame once an async data source (e.g. an image) has loaded, so the * reduced-motion single-frame path does not get stuck on a placeholder. */ redrawKey?: number | string | boolean; } /** * Shared canvas animation driver for the live Soft Club surfaces. * Handles devicePixelRatio scaling, resize observation, an optional pointer, * a throttled requestAnimationFrame loop, and reduced-motion (renders a single * static frame instead of animating). */ export function useCanvasAnimation( canvasRef: React.RefObject, hostRef: React.RefObject, draw: (frame: CanvasFrame) => void, options: UseCanvasAnimationOptions = {} ) { const { fps = 0, reactive = false, redrawKey } = options; const drawRef = React.useRef(draw); drawRef.current = draw; React.useEffect(() => { const canvas = canvasRef.current; const host = hostRef.current; if (!canvas || !host) return; const ctx = canvas.getContext("2d"); if (!ctx) return; let animationFrame = 0; let width = 0; let height = 0; let start = 0; let last = 0; let lastDraw = 0; const frameMs = fps > 0 ? 1000 / fps : 0; const pointer = { inside: false, x: -9999, y: -9999 }; const reduceMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; const resize = () => { const rect = host.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); width = rect.width; height = rect.height; }; const loop = (timeStamp: number) => { if (!start) { start = timeStamp; last = timeStamp; } if (frameMs && timeStamp - lastDraw < frameMs) { animationFrame = requestAnimationFrame(loop); return; } if (width === 0 || height === 0) resize(); const time = (timeStamp - start) / 1000; const dt = Math.min(0.064, (timeStamp - last) / 1000); last = timeStamp; lastDraw = timeStamp; drawRef.current({ ctx, dt, height, pointer, time, width }); animationFrame = requestAnimationFrame(loop); }; const onPointerMove = (event: MouseEvent) => { const rect = canvas.getBoundingClientRect(); pointer.x = event.clientX - rect.left; pointer.y = event.clientY - rect.top; pointer.inside = pointer.x >= -24 && pointer.x <= rect.width + 24 && pointer.y >= -24 && pointer.y <= rect.height + 24; }; const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host); resize(); if (reactive) window.addEventListener("mousemove", onPointerMove, { passive: true }); if (reduceMotion) { drawRef.current({ ctx, dt: 0, height, pointer, time: 0, width }); } else { animationFrame = requestAnimationFrame(loop); } return () => { cancelAnimationFrame(animationFrame); resizeObserver.disconnect(); if (reactive) window.removeEventListener("mousemove", onPointerMove); }; }, [canvasRef, fps, hostRef, reactive, redrawKey]); } ``` ### UseImageGrid (`use-image-grid`) Image sampling for the Soft Club canvas image effects (ASCII, mosaic, dither, halftone): loads a source image and reads it back as a cached, cover-fit RGBA grid, with a drifting procedural subject as the fallback when no image is set. - Registry item: https://cobanov.github.io/soft-club-ui/r/use-image-grid.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/use-image-grid.json` - npm dependencies: none - Registry dependencies: base - Installs to: `hooks/soft-club/use-image-grid.ts` ```ts "use client"; import * as React from "react"; /** * Image sampling for the Soft Club canvas image effects (ASCII, mosaic, dither, * halftone): loads a source image and reads it back as a cached, cover-fit RGBA * grid, with a drifting procedural subject as the fallback when no image is set. */ export interface ImageGrid { cols: number; rows: number; // Cover-fit RGBA bytes, row-major, length `cols * rows * 4`. data: Uint8ClampedArray; } export interface UseImageGrid { /** * Down-sample the loaded source image into a `cols x rows` RGBA grid, * cover-fitting and centering it. The result is cached until the grid size * or the `src` changes, so calling it every animation frame is cheap. * Returns `null` until the image has loaded (or if the canvas is tainted by * a cross-origin source), which is the cue to fall back to {@link fieldLuma}. */ sample: (cols: number, rows: number) => ImageGrid | null; /** * Bumps once the image settles (load or error). Pass it as the canvas * driver's `redrawKey` so the reduced-motion single static frame repaints * with the real image instead of staying on the procedural fallback. */ version: number; } /** Rec. 601 luminance (0..1) of an RGBA cell at byte offset `i`, alpha-weighted. */ export const cellLuma = (data: Uint8ClampedArray, i: number) => ((0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255) * (data[i + 3] / 255); /** * Drifting procedural luminance (0..1) for an off-center glowing subject, used * by every image effect when no `src` is supplied so the surfaces animate and * read as a silhouette on their own. `nx`/`ny` are normalized 0..1 cell * coordinates; `t` is seconds. */ export function fieldLuma(nx: number, ny: number, t: number): number { const cx = 0.5 + 0.05 * Math.sin(t * 0.4); const cy = 0.46 + 0.03 * Math.cos(t * 0.33); const dx = nx - cx; const dy = (ny - cy) * 1.22; const r = Math.sqrt(dx * dx * 1.15 + dy * dy); const core = Math.max(0, 1 - r * 1.85); const halo = Math.exp(-r * r * 5.5); const ripple = 0.5 + 0.5 * Math.sin(nx * 9 - t * 1.1) * Math.cos(ny * 7 + t * 0.9); const breath = 0.92 + 0.08 * Math.sin(t * 0.8); return Math.max(0, Math.min(1, (core * 0.82 + halo * 0.34) * breath + ripple * 0.1 * core)); } /** * Loads an image `src` and exposes a cached, cover-fit RGBA sampler for canvas * image effects (ASCII, mosaic, dither, halftone). Cross-origin reads are * attempted with `crossOrigin="anonymous"`; a tainted canvas degrades to * `null` rather than throwing, so callers fall back to the procedural field. */ export function useImageGrid(src?: string): UseImageGrid { const imageRef = React.useRef(null); const bufferRef = React.useRef(null); const cacheRef = React.useRef(null); const readyRef = React.useRef(false); const taintedRef = React.useRef(false); const [version, setVersion] = React.useState(0); React.useEffect(() => { readyRef.current = false; taintedRef.current = false; cacheRef.current = null; imageRef.current = null; if (!src || typeof window === "undefined") return; const image = new Image(); image.crossOrigin = "anonymous"; image.decoding = "async"; const settle = () => setVersion((value) => value + 1); const onLoad = () => { imageRef.current = image; cacheRef.current = null; readyRef.current = true; settle(); }; image.addEventListener("load", onLoad); image.addEventListener("error", settle); image.src = src; return () => { image.removeEventListener("load", onLoad); image.removeEventListener("error", settle); }; }, [src]); const sample = React.useCallback((cols: number, rows: number): ImageGrid | null => { const image = imageRef.current; if (!readyRef.current || taintedRef.current || !image || cols < 1 || rows < 1) return null; const cached = cacheRef.current; if (cached && cached.cols === cols && cached.rows === rows) return cached; let canvas = bufferRef.current; if (!canvas) { canvas = document.createElement("canvas"); bufferRef.current = canvas; } canvas.width = cols; canvas.height = rows; const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) return null; const iw = image.naturalWidth || image.width; const ih = image.naturalHeight || image.height; if (iw < 1 || ih < 1) return null; const scale = Math.max(cols / iw, rows / ih); const dw = iw * scale; const dh = ih * scale; ctx.clearRect(0, 0, cols, rows); ctx.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh); let data: Uint8ClampedArray; try { data = ctx.getImageData(0, 0, cols, rows).data; } catch { taintedRef.current = true; return null; } const grid: ImageGrid = { cols, data, rows }; cacheRef.current = grid; return grid; }, []); return { sample, version }; } ``` ### UseTokenStream (`use-token-stream`) Soft Club UseTokenStream hook. - Registry item: https://cobanov.github.io/soft-club-ui/r/use-token-stream.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/use-token-stream.json` - npm dependencies: none - Registry dependencies: base - Installs to: `hooks/soft-club/use-token-stream.ts` ```ts "use client"; import * as React from "react"; export interface UseTokenStreamOptions { enabled?: boolean; loop?: boolean; loopDelayMs?: number; onComplete?: () => void; speedMs?: number | [number, number]; text: string; tokenize?: (text: string) => string[]; } const tokenizeByWhitespace = (value: string) => value.split(/(\s+)/); const defaultSpeedMs: [number, number] = [18, 80]; export function useTokenStream({ enabled = true, loop = false, loopDelayMs = 6000, onComplete, speedMs = defaultSpeedMs, text, tokenize = tokenizeByWhitespace }: UseTokenStreamOptions) { const [output, setOutput] = React.useState(enabled ? "" : text); const [isComplete, setIsComplete] = React.useState(!enabled); // Keep the volatile options in a ref so inline `speedMs={[..]}` / `tokenize` // props do not re-trigger the effect mid-loop. The effect runs once per // (enabled, loop, text) and loops forever within that single lifecycle — // the same run-once algorithm the reference library uses. const optionsRef = React.useRef({ loopDelayMs, onComplete, speedMs, tokenize }); optionsRef.current = { loopDelayMs, onComplete, speedMs, tokenize }; React.useEffect(() => { if (!enabled) { setOutput(text); setIsComplete(true); return; } let cancelled = false; let timer: ReturnType | null = null; const tokens = optionsRef.current.tokenize(text); const pickDelay = () => { const speed = optionsRef.current.speedMs; return Array.isArray(speed) ? speed[0] + Math.random() * (speed[1] - speed[0]) : speed; }; const run = () => { let index = 0; let buffer = ""; const tick = () => { if (cancelled) return; if (index >= tokens.length) { setIsComplete(true); optionsRef.current.onComplete?.(); if (loop) { timer = setTimeout(() => { if (cancelled) return; setIsComplete(false); setOutput(""); run(); }, optionsRef.current.loopDelayMs); } return; } buffer += tokens[index] ?? ""; index += 1; setOutput(buffer); timer = setTimeout(tick, pickDelay()); }; tick(); }; setOutput(""); setIsComplete(false); run(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [enabled, loop, text]); return { isComplete, isStreaming: !isComplete, output }; } ``` ### UseTypewriter (`use-typewriter`) Soft Club UseTypewriter hook. - Registry item: https://cobanov.github.io/soft-club-ui/r/use-typewriter.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/use-typewriter.json` - npm dependencies: none - Registry dependencies: base - Installs to: `hooks/soft-club/use-typewriter.ts` ```ts "use client"; import * as React from "react"; export interface UseTypewriterOptions { deleteMs?: number; holdMs?: number; loop?: boolean; onWordReached?: (word: string, index: number) => void; typeMs?: number; words: string[]; } export function useTypewriter({ deleteMs = 32, holdMs = 1500, loop = true, onWordReached, typeMs = 70, words }: UseTypewriterOptions) { const [word, setWord] = React.useState(""); const [index, setIndex] = React.useState(0); const [isDeleting, setIsDeleting] = React.useState(false); const [isComplete, setIsComplete] = React.useState(false); const stateRef = React.useRef({ index, isDeleting, word }); stateRef.current = { index, isDeleting, word }; React.useEffect(() => { if (words.length === 0) return; let timer: ReturnType | undefined; let cancelled = false; const tick = () => { if (cancelled) return; const current = stateRef.current; const target = words[current.index] ?? ""; const next = current.isDeleting ? target.slice(0, current.word.length - 1) : target.slice(0, current.word.length + 1); setWord(next); if (!current.isDeleting && next === target) { onWordReached?.(target, current.index); if (!loop && current.index === words.length - 1) { setIsComplete(true); return; } timer = setTimeout(() => { setIsDeleting(true); timer = setTimeout(tick, deleteMs); }, holdMs); return; } if (current.isDeleting && next === "") { setIsDeleting(false); setIndex((value) => (value + 1) % words.length); } timer = setTimeout(tick, current.isDeleting ? deleteMs : typeMs); }; timer = setTimeout(tick, typeMs); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [deleteMs, holdMs, loop, onWordReached, typeMs, words]); return { index, isComplete, isDeleting, word }; } ``` ## Components ### Accordion (`accordion`) Disclosure list for compact support content and specs. - Registry item: https://cobanov.github.io/soft-club-ui/r/accordion.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/accordion.json` - npm dependencies: none - Registry dependencies: base - Installs to: `components/soft-club/accordion.tsx` ```tsx "use client"; import * as React from "react"; import { cx } from "@/lib/soft-club/cx"; export type AccordionProps = React.HTMLAttributes; export type AccordionItemProps = React.DetailsHTMLAttributes; export type AccordionTriggerProps = React.HTMLAttributes; export type AccordionContentProps = React.HTMLAttributes; const AccordionRoot = React.forwardRef( ({ className, ...props }, ref) => (
) ); AccordionRoot.displayName = "Accordion"; const AccordionItem = React.forwardRef( ({ className, ...props }, ref) => (
) ); AccordionItem.displayName = "AccordionItem"; const AccordionTrigger = React.forwardRef( ({ children, className, ...props }, ref) => ( {children} ) ); AccordionTrigger.displayName = "AccordionTrigger"; const AccordionContent = React.forwardRef( ({ className, ...props }, ref) => (
) ); AccordionContent.displayName = "AccordionContent"; export const Accordion = Object.assign(AccordionRoot, { Content: AccordionContent, Item: AccordionItem, Trigger: AccordionTrigger }); ``` ### AccretionCluster (`accretion-cluster`) Diffusion-limited aggregation. The strata bands in the crust record arrival order. - Registry item: https://cobanov.github.io/soft-club-ui/r/accretion-cluster.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/accretion-cluster.json` - npm dependencies: none - Registry dependencies: base, theme-color, use-canvas-animation, instrument - Installs to: `components/soft-club/accretion-cluster.tsx` ```tsx "use client"; import * as React from "react"; import { useCanvasAnimation } from "@/hooks/soft-club/use-canvas-animation"; import { rgba, themeRgb } from "@/lib/soft-club/theme-color"; import { cx } from "@/lib/soft-club/cx"; import { useInstrument } from "@/components/soft-club/instrument"; export interface AccretionClusterProps extends React.HTMLAttributes { /** Strata bands used to shade deposits by arrival order. */ bands?: number; label?: string; /** Random walkers launched per frame. */ walkers?: number; } const GRID = 168; interface AccretionSim { count: number; order: Int32Array; radius: number; } /** * Witten-Sander diffusion-limited aggregation: a mineral that remembers its * own arrival order. Nothing is drawn — everything is deposited, one lost * particle at a time, and the strata bands in the crust are the archive of * when. Regen starts a new seed crystal. */ export const AccretionCluster = React.forwardRef( ({ bands = 7, className, label, walkers = 220, ...props }, ref) => { const hostRef = React.useRef(null); const canvasRef = React.useRef(null); const simRef = React.useRef(null); const epochRef = React.useRef(-1); const { epoch } = useInstrument(); useCanvasAnimation( canvasRef, hostRef, ({ ctx, height, time, width }) => { let sim = simRef.current; if (!sim || epochRef.current !== epoch) { sim = { count: 1, order: new Int32Array(GRID * GRID), radius: 4 }; sim.order[(GRID / 2) * GRID + GRID / 2] = 1; simRef.current = sim; epochRef.current = epoch; } const center = GRID / 2; const { order } = sim; const stuck = (x: number, y: number) => x > 0 && x < GRID - 1 && y > 0 && y < GRID - 1 && (order[y * GRID + x - 1] > 0 || order[y * GRID + x + 1] > 0 || order[(y - 1) * GRID + x] > 0 || order[(y + 1) * GRID + x] > 0); const spawnRadius = Math.min(GRID / 2 - 4, sim.radius + 8); const killRadius = Math.min(GRID / 2 - 2, spawnRadius + 10); if (sim.count < 5200) { for (let w = 0; w < walkers; w += 1) { const startAngle = Math.random() * Math.PI * 2; let x = Math.round(center + Math.cos(startAngle) * spawnRadius); let y = Math.round(center + Math.sin(startAngle) * spawnRadius); for (let step = 0; step < 420; step += 1) { x += Math.random() < 0.5 ? -1 : 1; y += Math.random() < 0.5 ? -1 : 1; const dx = x - center; const dy = y - center; if (dx * dx + dy * dy > killRadius * killRadius) break; if (stuck(x, y)) { sim.count += 1; order[y * GRID + x] = sim.count; const reach = Math.sqrt(dx * dx + dy * dy); if (reach > sim.radius) sim.radius = reach; break; } } } } const primary = themeRgb(hostRef.current, "--sc-theme-rgb-primary", [142, 255, 173]); const secondary = themeRgb(hostRef.current, "--sc-theme-rgb-secondary", [139, 184, 215]); const cell = Math.min(width, height) / GRID; const offsetX = (width - GRID * cell) / 2; const offsetY = (height - GRID * cell) / 2; ctx.clearRect(0, 0, width, height); for (let gy = 0; gy < GRID; gy += 1) { for (let gx = 0; gx < GRID; gx += 1) { const arrival = order[gy * GRID + gx]; if (arrival === 0) continue; const band = Math.floor((arrival / Math.max(1, sim.count)) * bands); const tone = 0.3 + (band / Math.max(1, bands - 1)) * 0.65; ctx.fillStyle = rgba(primary, tone); ctx.fillRect( offsetX + gx * cell, offsetY + gy * cell, Math.max(1, cell), Math.max(1, cell) ); } } // Dashed spawn ring breathing around the crust. ctx.strokeStyle = rgba(secondary, 0.4); ctx.setLineDash([4, 6]); ctx.lineDashOffset = -time * 8; ctx.beginPath(); ctx.arc( offsetX + center * cell, offsetY + center * cell, spawnRadius * cell, 0, Math.PI * 2 ); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = rgba(primary, 0.7); ctx.font = "9px monospace"; ctx.fillText( `DEPOSITS ${sim.count} WALKERS ${walkers} RADIUS ${Math.round(sim.radius)} BANDS ${bands}`, 8, height - 8 ); }, { fps: 30 } ); const setRef = (node: HTMLDivElement | null) => { hostRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }; return (
); } ); AccretionCluster.displayName = "AccretionCluster"; ``` ### AlertDialog (`alert-dialog`) Confirmation modal that requires an explicit choice before closing. - Registry item: https://cobanov.github.io/soft-club-ui/r/alert-dialog.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/alert-dialog.json` - npm dependencies: @radix-ui/react-dialog@^1.1.6 - Registry dependencies: base, button - Installs to: `components/soft-club/alert-dialog.tsx` ```tsx "use client"; import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { cx } from "@/lib/soft-club/cx"; import { Button, type ButtonProps } from "@/components/soft-club/button"; export const AlertDialog = DialogPrimitive.Root; export const AlertDialogTrigger = DialogPrimitive.Trigger; export const AlertDialogPortal = DialogPrimitive.Portal; export const AlertDialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); AlertDialogOverlay.displayName = DialogPrimitive.Overlay.displayName; /** A confirmation dialog: role=alertdialog, and outside clicks do not dismiss. */ export const AlertDialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( event.preventDefault()} ref={ref} role="alertdialog" {...props} /> )); AlertDialogContent.displayName = DialogPrimitive.Content.displayName; export const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
); AlertDialogHeader.displayName = "AlertDialogHeader"; export const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
); AlertDialogFooter.displayName = "AlertDialogFooter"; export const AlertDialogTitle = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); AlertDialogTitle.displayName = DialogPrimitive.Title.displayName; export const AlertDialogDescription = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); AlertDialogDescription.displayName = DialogPrimitive.Description.displayName; /** Confirms the action and closes the dialog. */ export const AlertDialogAction = React.forwardRef( (props, ref) => ( ) : null}
); } ); AnnouncementBar.displayName = "AnnouncementBar"; ``` ### AsciiBackdrop (`ascii-backdrop`) Deterministic PRNG so a given seed always renders the same field. - Registry item: https://cobanov.github.io/soft-club-ui/r/ascii-backdrop.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/ascii-backdrop.json` - npm dependencies: none - Registry dependencies: base - Installs to: `components/soft-club/ascii-backdrop.tsx` ```tsx "use client"; import * as React from "react"; import { cx } from "@/lib/soft-club/cx"; type Rgb = [number, number, number]; const TAU = Math.PI * 2; /** Deterministic PRNG so a given seed always renders the same field. */ function mulberry32(a: number) { return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function hslToRgb(h: number, s: number, l: number): Rgb { const hn = (((h % 360) + 360) % 360) / 360; const a = s * Math.min(l, 1 - l); const f = (n: number) => { const k = (n + hn * 12) % 12; return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); }; return [f(0) * 255, f(8) * 255, f(4) * 255]; } function hexToRgb(hex: string): Rgb | null { const m = hex.trim().replace(/^#/, ""); if (m.length === 3) { const r = parseInt(m[0] + m[0], 16); const g = parseInt(m[1] + m[1], 16); const b = parseInt(m[2] + m[2], 16); return [r, g, b].some((v) => Number.isNaN(v)) ? null : [r, g, b]; } if (m.length === 6) { const r = parseInt(m.slice(0, 2), 16); const g = parseInt(m.slice(2, 4), 16); const b = parseInt(m.slice(4, 6), 16); return [r, g, b].some((v) => Number.isNaN(v)) ? null : [r, g, b]; } return null; } function smoothstep(e0: number, e1: number, x: number) { const t = Math.max(0, Math.min(1, (x - e0) / (e1 - e0))); return t * t * (3 - 2 * t); } interface Center { x: number; y: number; sigma: number; weight: number; color: Rgb; } interface Field { centers: Center[]; warp: { fx: number; fy: number; px: number; py: number; amp: number }; bg: Rgb; } /** * Turns a seed into a smooth, organic, multi-colour field: a handful of soft * colour centres plus a gentle domain warp. Same seed -> identical field. */ function buildField(seed: number, palette?: Rgb[]): Field { const rnd = mulberry32(seed >>> 0); const baseHue = rnd() * 360; const n = 3 + Math.floor(rnd() * 3); // 3..5 colour centres const scheme = [0, 45 + rnd() * 75, -(45 + rnd() * 75), 150 + rnd() * 70, -150 - rnd() * 40]; const centers: Center[] = []; for (let i = 0; i < n; i += 1) { const hue = baseHue + scheme[i % scheme.length] + (rnd() - 0.5) * 20; const sat = 0.5 + rnd() * 0.26; const lig = 0.56 + rnd() * 0.12; const color = palette && palette.length > 0 ? palette[i % palette.length] : hslToRgb(hue, sat, lig); centers.push({ x: 0.1 + rnd() * 0.8, y: 0.1 + rnd() * 0.8, sigma: 0.13 + rnd() * 0.2, weight: 1 + rnd() * 0.8, color }); } const warp = { fx: 0.5 + rnd() * 0.9, fy: 0.5 + rnd() * 0.9, px: rnd() * TAU, py: rnd() * TAU, amp: 0.04 + rnd() * 0.06 }; const bg = hslToRgb(baseHue + (rnd() - 0.5) * 40, 0.22 + rnd() * 0.18, 0.95 + rnd() * 0.03); return { centers, warp, bg }; } interface RenderOptions { cell: number; seed: number; palette?: Rgb[]; glyphs?: string; background?: string; } /** Paints one static halftone-dot frame for the given seed. */ function renderBackdrop( ctx: CanvasRenderingContext2D, width: number, height: number, options: RenderOptions ) { const { cell, seed, palette, glyphs, background } = options; const { centers, warp, bg } = buildField(seed, palette); ctx.fillStyle = background ?? `rgb(${bg[0] | 0}, ${bg[1] | 0}, ${bg[2] | 0})`; ctx.fillRect(0, 0, width, height); const cols = Math.max(1, Math.ceil(width / cell)); const rows = Math.max(1, Math.ceil(height / cell)); const asp = height > 0 ? width / height : 1; if (glyphs) { ctx.font = `${cell}px var(--sc-font-mono, monospace)`; ctx.textBaseline = "middle"; ctx.textAlign = "center"; } for (let gy = 0; gy < rows; gy += 1) { const v = (gy + 0.5) / rows; for (let gx = 0; gx < cols; gx += 1) { const u = (gx + 0.5) / cols; const wu = u + warp.amp * Math.sin(v * warp.fy * TAU + warp.px); const wv = v + warp.amp * Math.sin(u * warp.fx * TAU + warp.py); let r = 0; let g = 0; let b = 0; let wsum = 0; let cover = 0; for (const c of centers) { const dx = (wu - c.x) * asp; const dy = wv - c.y; const w = c.weight * Math.exp(-(dx * dx + dy * dy) / (2 * c.sigma * c.sigma)); const cw = w * w; // favour the dominant centre so hues stay pure r += c.color[0] * cw; g += c.color[1] * cw; b += c.color[2] * cw; wsum += cw; cover += w; } if (wsum > 0) { r /= wsum; g /= wsum; b /= wsum; } const ink = smoothstep(0.12, 0.9, cover); if (ink <= 0.001) continue; const px = gx * cell + cell / 2; const py = gy * cell + cell / 2; const color = `rgb(${r | 0}, ${g | 0}, ${b | 0})`; if (glyphs) { const ch = glyphs[Math.min(glyphs.length - 1, Math.floor(ink * glyphs.length))] ?? ""; if (ch === " " || ch === "") continue; ctx.globalAlpha = Math.max(0.18, ink); ctx.fillStyle = color; ctx.fillText(ch, px, py); } else { const radius = cell * 0.56 * Math.pow(ink, 0.62); if (radius < 0.3) continue; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(px, py, radius, 0, TAU); ctx.fill(); } } } ctx.globalAlpha = 1; } export interface AsciiBackdropProps extends React.HTMLAttributes { /** Background fill behind the dots. Defaults to a seeded near-white tint. */ background?: string; /** Grid spacing in pixels. Smaller = finer dot screen. */ cell?: number; /** Render ASCII glyphs from this ramp (low->high density) instead of dots. */ glyphs?: string; label?: string; /** Override the generated colours with brand hex colours (e.g. ["#ff9a3d", "#8effad"]). */ palette?: string[]; /** Any number; each seed deterministically generates a unique field. */ seed?: number; } /** * A static, seeded halftone-dot background: a soft organic colour field sampled * into a grid of dots whose size tracks the local ink. Every `seed` renders a * unique, reproducible composition — good for landing pages, product visuals, * and branding. */ export const AsciiBackdrop = React.forwardRef( ({ background, cell = 7, className, glyphs, label, palette, seed = 1, ...props }, ref) => { const hostRef = React.useRef(null); const canvasRef = React.useRef(null); const paletteKey = palette?.join(",") ?? ""; React.useEffect(() => { const canvas = canvasRef.current; const host = hostRef.current; if (!canvas || !host) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const parsedPalette = paletteKey ? (paletteKey .split(",") .map(hexToRgb) .filter((c): c is Rgb => c !== null) as Rgb[]) : undefined; const paint = () => { const rect = host.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); renderBackdrop(ctx, rect.width, rect.height, { background, cell: Math.max(3, cell), glyphs, palette: parsedPalette && parsedPalette.length > 0 ? parsedPalette : undefined, seed }); }; paint(); const resizeObserver = new ResizeObserver(paint); resizeObserver.observe(host); return () => resizeObserver.disconnect(); }, [background, cell, glyphs, paletteKey, seed]); const setRef = (node: HTMLDivElement | null) => { hostRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }; return (
); } ); AsciiBackdrop.displayName = "AsciiBackdrop"; ``` ### AsciiDonut (`ascii-donut`) Soft Club AsciiDonut component. - Registry item: https://cobanov.github.io/soft-club-ui/r/ascii-donut.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/ascii-donut.json` - npm dependencies: none - Registry dependencies: base, theme-color, use-canvas-animation - Installs to: `components/soft-club/ascii-donut.tsx` ```tsx "use client"; import * as React from "react"; import { useCanvasAnimation } from "@/hooks/soft-club/use-canvas-animation"; import { rgba, themeRgb } from "@/lib/soft-club/theme-color"; import { cx } from "@/lib/soft-club/cx"; const RAMP = ".,-~:;=!*#$@"; export interface AsciiDonutProps extends React.HTMLAttributes { label?: string; cell?: number; spin?: number; } export const AsciiDonut = React.forwardRef( ({ className, label, cell = 9, spin = 1, ...props }, ref) => { const hostRef = React.useRef(null); const canvasRef = React.useRef(null); const state = React.useRef({ a: 0, b: 0, cols: 0, rows: 0, w: 0, h: 0, z: new Float32Array(0) }); useCanvasAnimation( canvasRef, hostRef, ({ ctx, width, height, dt, pointer }) => { const st = state.current; const cellH = cell * 1.15; const cols = Math.max(1, Math.floor(width / cell)); const rows = Math.max(1, Math.floor(height / cellH)); if (st.w !== width || st.h !== height || st.cols !== cols || st.rows !== rows) { st.w = width; st.h = height; st.cols = cols; st.rows = rows; st.z = new Float32Array(cols * rows); } const primary = themeRgb(hostRef.current, "--sc-theme-rgb-primary", [142, 255, 173]); const text = themeRgb(hostRef.current, "--sc-theme-rgb-text", [247, 255, 248]); st.a += dt * 0.9 * spin; st.b += dt * 0.55 * spin; // Light direction: defaults near (0,1,-1); steers toward pointer when inside. let lx = 0; let ly = 1; let lz = -1; let tiltX = 0.62; // flatten toward edge-on if (pointer.inside) { const px = (pointer.x / width) * 2 - 1; const py = (pointer.y / height) * 2 - 1; lx = px; ly = -py; lz = -0.9; tiltX += -py * 0.28; } const lmag = Math.hypot(lx, ly, lz) || 1; lx /= lmag; ly /= lmag; lz /= lmag; const cA = Math.cos(st.a); const sA = Math.sin(st.a); const cB = Math.cos(st.b); const sB = Math.sin(st.b); const cT = Math.cos(tiltX); const sT = Math.sin(tiltX); const r1 = 1; const r2 = 2; const K2 = 5; const K1 = (width * 0.7) / (cell * (K2 / (r1 + r2)) * 2); const cx0 = cols / 2; const cy0 = rows / 2; const z = st.z; z.fill(0); ctx.clearRect(0, 0, width, height); ctx.font = `${cell}px var(--sc-font-mono, monospace)`; ctx.textBaseline = "top"; const glyph: number[] = new Array(cols * rows).fill(-1); for (let theta = 0; theta < Math.PI * 2; theta += 0.07) { const ct = Math.cos(theta); const stt = Math.sin(theta); for (let phi = 0; phi < Math.PI * 2; phi += 0.02) { const cp = Math.cos(phi); const sp = Math.sin(phi); const circleX = r2 + r1 * ct; // Spin around z (B), then around x (A), then a static tilt to flatten. const ox = circleX * (cB * cp + sA * sB * sp) - r1 * stt * cA * sB; const oy = circleX * (sB * cp - sA * cB * sp) + r1 * stt * cA * cB; const oz = cA * circleX * sp + r1 * stt * sA; const y2 = oy * cT - oz * sT; const z2 = oy * sT + oz * cT + (r2 + 3); const ooz = 1 / z2; const xp = Math.floor(cx0 + K1 * ooz * ox); const yp = Math.floor(cy0 - K1 * ooz * y2 * 0.55); if (xp < 0 || xp >= cols || yp < 0 || yp >= rows) continue; const idx = xp + yp * cols; // Surface normal (same rotations as the point). const nxr = ct * (cB * cp + sA * sB * sp) - stt * cA * sB; const nyr = ct * (sB * cp - sA * cB * sp) + stt * cA * cB; const nzr = cA * ct * sp + stt * sA; const ny2 = nyr * cT - nzr * sT; const nz2 = nyr * sT + nzr * cT; const lum = nxr * lx + ny2 * ly + nz2 * lz; if (lum <= 0) continue; if (ooz > (z[idx] ?? 0)) { z[idx] = ooz; glyph[idx] = Math.min(RAMP.length - 1, Math.floor(lum * 9)); } } } for (let row = 0; row < rows; row += 1) { for (let col = 0; col < cols; col += 1) { const idx = col + row * cols; const gi = glyph[idx] ?? -1; if (gi < 0) continue; const t = gi / (RAMP.length - 1); const r = primary[0] + (text[0] - primary[0]) * t; const g = primary[1] + (text[1] - primary[1]) * t; const b = primary[2] + (text[2] - primary[2]) * t; ctx.fillStyle = rgba([r, g, b], 0.5 + t * 0.5); ctx.fillText(RAMP.charAt(gi), col * cell, row * cellH); } } }, { fps: 30, reactive: true } ); const setRef = (node: HTMLDivElement | null) => { hostRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }; return (
); } ); AsciiDonut.displayName = "AsciiDonut"; ``` ### AsciiFire (`ascii-fire`) Soft Club AsciiFire component. - Registry item: https://cobanov.github.io/soft-club-ui/r/ascii-fire.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/ascii-fire.json` - npm dependencies: none - Registry dependencies: base, theme-color, use-canvas-animation - Installs to: `components/soft-club/ascii-fire.tsx` ```tsx "use client"; import * as React from "react"; import { useCanvasAnimation } from "@/hooks/soft-club/use-canvas-animation"; import { rgba, themeRgb } from "@/lib/soft-club/theme-color"; import { cx } from "@/lib/soft-club/cx"; export interface AsciiFireProps extends React.HTMLAttributes { label?: string; cell?: number; cooling?: number; } const RAMP = " .:*oO#@"; export const AsciiFire = React.forwardRef( ({ className, label, cell = 10, cooling = 0.93, ...props }, ref) => { const hostRef = React.useRef(null); const canvasRef = React.useRef(null); const state = React.useRef({ heat: new Float32Array(0), cols: 0, rows: 0, w: 0, h: 0 }); useCanvasAnimation( canvasRef, hostRef, ({ ctx, height, pointer, width }) => { const st = state.current; const cellH = cell * 1.15; const cols = Math.max(1, Math.floor(width / cell)); const rows = Math.max(2, Math.floor(height / cellH)); if (st.w !== width || st.h !== height) { st.w = width; st.h = height; st.cols = cols; st.rows = rows; st.heat = new Float32Array(cols * rows); } const heat = st.heat; const idx = (x: number, y: number) => y * cols + x; // Seed the bottom row with random high heat; bias toward pointer column (wind). const windCol = pointer.inside ? Math.floor(pointer.x / cell) : -1; for (let x = 0; x < cols; x += 1) { let seed = 0.7 + Math.random() * 0.3; if (windCol >= 0) { const near = 1 - Math.min(1, Math.abs(x - windCol) / (cols * 0.4)); seed = Math.min(1, seed + near * 0.35 * Math.random()); } heat[idx(x, rows - 1)] = seed; } // Inject a torch around the pointer. if (pointer.inside) { const px = Math.floor(pointer.x / cell); const py = Math.floor(pointer.y / cellH); for (let dy = -2; dy <= 2; dy += 1) { for (let dx = -2; dx <= 2; dx += 1) { const tx = px + dx; const ty = py + dy; if (tx < 0 || tx >= cols || ty < 0 || ty >= rows) continue; const falloff = 1 - Math.min(1, Math.hypot(dx, dy) / 2.6); const cur = heat[idx(tx, ty)] ?? 0; heat[idx(tx, ty)] = Math.min(1, cur + falloff * (0.6 + Math.random() * 0.4)); } } } // Propagate upward. for (let y = 0; y < rows - 1; y += 1) { for (let x = 0; x < cols; x += 1) { const bl = heat[idx(Math.max(0, x - 1), y + 1)] ?? 0; const bc = heat[idx(x, y + 1)] ?? 0; const br = heat[idx(Math.min(cols - 1, x + 1), y + 1)] ?? 0; const cool = cooling + (Math.random() - 0.5) * 0.025; heat[idx(x, y)] = Math.max(0, ((bl + bc + br) / 3) * cool); } } const primary = themeRgb(hostRef.current, "--sc-theme-rgb-primary", [142, 255, 173]); const text = themeRgb(hostRef.current, "--sc-theme-rgb-text", [247, 255, 248]); ctx.clearRect(0, 0, width, height); ctx.font = `${cell}px var(--sc-font-mono, monospace)`; ctx.textBaseline = "top"; for (let y = 0; y < rows; y += 1) { for (let x = 0; x < cols; x += 1) { const h = heat[idx(x, y)] ?? 0; if (h < 0.06) continue; const ramp = Math.min(RAMP.length - 1, Math.floor(h * RAMP.length)); const char = RAMP[ramp] ?? " "; if (char === " ") continue; const blend: [number, number, number] = [ primary[0] + (text[0] - primary[0]) * h, primary[1] + (text[1] - primary[1]) * h, primary[2] + (text[2] - primary[2]) * h ]; ctx.fillStyle = rgba(blend, 0.3 + h * 0.7); ctx.fillText(char, x * cell, y * cellH); } } }, { fps: 30, reactive: true } ); const setRef = (node: HTMLDivElement | null) => { hostRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }; return (
); } ); AsciiFire.displayName = "AsciiFire"; ``` ### AsciiFlow (`ascii-flow`) Soft Club AsciiFlow component. - Registry item: https://cobanov.github.io/soft-club-ui/r/ascii-flow.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/ascii-flow.json` - npm dependencies: none - Registry dependencies: base, theme-color, use-canvas-animation - Installs to: `components/soft-club/ascii-flow.tsx` ```tsx "use client"; import * as React from "react"; import { useCanvasAnimation } from "@/hooks/soft-club/use-canvas-animation"; import { rgba, themeRgb } from "@/lib/soft-club/theme-color"; import { cx } from "@/lib/soft-club/cx"; const RAMP = " .:-=+*#%@"; function hash2(ix: number, iy: number): number { let h = (ix * 374761393 + iy * 668265263) | 0; h = (h ^ (h >>> 13)) * 1274126177; return ((h ^ (h >>> 16)) >>> 0) / 4294967295; } function smooth(t: number): number { return t * t * (3 - 2 * t); } function valueNoise(x: number, y: number): number { const ix = Math.floor(x); const iy = Math.floor(y); const fx = smooth(x - ix); const fy = smooth(y - iy); const a = hash2(ix, iy); const b = hash2(ix + 1, iy); const c = hash2(ix, iy + 1); const d = hash2(ix + 1, iy + 1); const top = a + (b - a) * fx; const bot = c + (d - c) * fx; return top + (bot - top) * fy; } export interface AsciiFlowProps extends React.HTMLAttributes { label?: string; cell?: number; threshold?: number; } export const AsciiFlow = React.forwardRef( ({ className, label, cell = 10, threshold = 0.42, ...props }, ref) => { const hostRef = React.useRef(null); const canvasRef = React.useRef(null); const state = React.useRef({ w: 0, h: 0 }); useCanvasAnimation( canvasRef, hostRef, ({ ctx, height, pointer, time, width }) => { const st = state.current; if (st.w !== width || st.h !== height) { st.w = width; st.h = height; } const primary = themeRgb(hostRef.current, "--sc-theme-rgb-primary", [142, 255, 173]); const text = themeRgb(hostRef.current, "--sc-theme-rgb-text", [247, 255, 248]); const cellH = cell * 1.15; const cols = Math.floor(width / cell); const rows = Math.floor(height / cellH); const t = time; ctx.clearRect(0, 0, width, height); ctx.font = `${cell}px var(--sc-font-mono, monospace)`; ctx.textBaseline = "top"; for (let row = 0; row < rows; row += 1) { const py = row * cellH; for (let col = 0; col < cols; col += 1) { const px = col * cell; const n1 = valueNoise(col * 0.12 + t * 0.15, row * 0.14 - t * 0.1); const n2 = valueNoise(col * 0.045 - t * 0.06, row * 0.05 + t * 0.08); let v01 = n1 * 0.65 + n2 * 0.35; if (pointer.inside) { const dx = px - pointer.x; const dy = py - pointer.y; const d2 = dx * dx + dy * dy; const radius = 90; v01 += Math.exp(-d2 / (radius * radius)) * 0.55; } v01 = v01 < 0 ? 0 : v01 > 1 ? 1 : v01; if (v01 <= threshold) continue; const k = (v01 - threshold) / (1 - threshold); const ci = Math.min(RAMP.length - 1, Math.floor(k * RAMP.length)); const char = RAMP[ci] ?? "@"; const r = primary[0] + (text[0] - primary[0]) * k; const g = primary[1] + (text[1] - primary[1]) * k; const b = primary[2] + (text[2] - primary[2]) * k; ctx.fillStyle = rgba([r, g, b] as [number, number, number], 0.4 + k * 0.55); ctx.fillText(char, px, py); } } }, { fps: 30, reactive: true } ); const setRef = (node: HTMLDivElement | null) => { hostRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }; return (
); } ); AsciiFlow.displayName = "AsciiFlow"; ``` ### AsciiHero (`ascii-hero`) Reactive ASCII canvas adapted into the green glass surface. - Registry item: https://cobanov.github.io/soft-club-ui/r/ascii-hero.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/ascii-hero.json` - npm dependencies: none - Registry dependencies: base, use-ascii-field - Installs to: `components/soft-club/ascii-hero.tsx` ```tsx "use client"; import * as React from "react"; import { useAsciiField, type UseAsciiFieldOptions } from "@/hooks/soft-club/use-ascii-field"; import { cx } from "@/lib/soft-club/cx"; const defaultArt = String.raw` 133G04 A1 B2 C3 +--------------------------------+ | //// SOFT CLUB //// | | __ green glass __ | | / \ signal bus / \ | | \__/ scan line \__/ | +--------------------------------+ D1 UPDATED F4 `; export interface AsciiHeroProps extends React.HTMLAttributes, UseAsciiFieldOptions { art?: string; label?: string; subtitle?: string; title?: string; variant?: "banner" | "bare" | "panel"; } export const AsciiHero = React.forwardRef( ( { art = defaultArt, baseOpacity, charRamp, className, cols, colorful, fontFamily, fontSize, frameMs, label = "SC / ASCII FIELD", palette, reactive, rippleRadius, rippleStrength, rows, spotlightOpacity, spotlightRadius, subtitle = "Late-night interface surface with scanline text, cold glass, and technical framing.", title = "young adult glass system", variant = "panel", ...props }, ref ) => { const hostRef = React.useRef(null); const canvasRef = React.useRef(null); useAsciiField(canvasRef, hostRef, { baseOpacity, charRamp, cols, colorful, fontFamily, fontSize, frameMs, palette, reactive, rippleRadius, rippleStrength, rows, spotlightOpacity, spotlightRadius }); const setRef = (node: HTMLElement | null) => { hostRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }; return (
); } ); AsciiHero.displayName = "AsciiHero"; ``` ### AsciiImage (`ascii-image`) Renders an image or procedural subject as a live, theme-tinted field of ASCII glyphs. - Registry item: https://cobanov.github.io/soft-club-ui/r/ascii-image.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/ascii-image.json` - npm dependencies: none - Registry dependencies: base, theme-color, use-canvas-animation, use-image-grid - Installs to: `components/soft-club/ascii-image.tsx` ```tsx "use client"; import * as React from "react"; import { useCanvasAnimation } from "@/hooks/soft-club/use-canvas-animation"; import { cellLuma, fieldLuma, useImageGrid } from "@/hooks/soft-club/use-image-grid"; import { rgba, themeRgb } from "@/lib/soft-club/theme-color"; import { cx } from "@/lib/soft-club/cx"; export interface AsciiImageProps extends React.HTMLAttributes { /** Glyphs ordered dark to light. Defaults to binary for the code-rain look. */ charRamp?: string; /** Glyph cell size in px. */ fontSize?: number; label?: string; /** Animation speed multiplier. */ speed?: number; /** Source image. Omit for a self-contained procedural subject. */ src?: string; } const defaultRamp = "01"; /** * Renders a source image (or a procedural subject) as a live field of ASCII * glyphs: luminance picks the character and the ink opacity, bright cells flicker * and pick up a faint red/blue chromatic split, and the pointer lifts a spotlight * through the grid. Tinted to the active theme; pass `charRamp="01"` for binary * code rain or a glyph set like arrows for a denser screen. */ export const AsciiImage = React.forwardRef( ({ charRamp = defaultRamp, className, fontSize = 12, label, speed = 1, src, ...props }, ref) => { const hostRef = React.useRef(null); const canvasRef = React.useRef(null); const { sample, version } = useImageGrid(src); const ramp = charRamp.length > 0 ? charRamp : defaultRamp; useCanvasAnimation( canvasRef, hostRef, ({ ctx, height, pointer, time, width }) => { const cellW = fontSize * 0.62; const cellH = fontSize * 1.16; const cols = Math.max(1, Math.floor(width / cellW)); const rows = Math.max(1, Math.floor(height / cellH)); const grid = sample(cols, rows); const frame = time * speed; const primary = themeRgb(hostRef.current, "--sc-theme-rgb-primary", [142, 255, 173]); const secondary = themeRgb(hostRef.current, "--sc-theme-rgb-secondary", [139, 184, 215]); const spot = Math.min(width, height) * 0.32; ctx.clearRect(0, 0, width, height); ctx.font = `${fontSize}px var(--sc-font-mono, monospace)`; ctx.textBaseline = "top"; for (let row = 0; row < rows; row += 1) { for (let col = 0; col < cols; col += 1) { const lum = grid ? cellLuma(grid.data, (row * cols + col) * 4) : fieldLuma(col / cols, row / rows, frame); if (lum < 0.08) continue; const flick = Math.sin(col * 12.9898 + row * 78.233 + Math.floor(frame * 3) * 0.6); const idx = Math.min(ramp.length - 1, Math.floor(lum * ramp.length + (flick > 0 ? 1 : 0))); const glyph = ramp[Math.max(0, idx)]; if (!glyph || glyph === " ") continue; const x = col * cellW; const y = row * cellH; let alpha = 0.32 + lum * 0.62; if (pointer.inside) { const dx = x - pointer.x; const dy = y - pointer.y; alpha += Math.exp(-(dx * dx + dy * dy) / (spot * spot)) * 0.5; } alpha = Math.min(1, alpha); if (lum > 0.55) { ctx.globalAlpha = alpha * 0.26; ctx.fillStyle = "rgb(255, 86, 86)"; ctx.fillText(glyph, x + 0.8, y); ctx.fillStyle = "rgb(96, 142, 255)"; ctx.fillText(glyph, x - 0.8, y); } ctx.globalAlpha = alpha; ctx.fillStyle = rgba(lum > 0.5 ? primary : secondary, 1); ctx.fillText(glyph, x, y); } } ctx.globalAlpha = 1; }, { fps: 24, reactive: true, redrawKey: version } ); const setRef = (node: HTMLDivElement | null) => { hostRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }; return (
); } ); AsciiImage.displayName = "AsciiImage"; ``` ### AsciiLife (`ascii-life`) Soft Club AsciiLife component. - Registry item: https://cobanov.github.io/soft-club-ui/r/ascii-life.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/ascii-life.json` - npm dependencies: none - Registry dependencies: base, theme-color, use-canvas-animation - Installs to: `components/soft-club/ascii-life.tsx` ```tsx "use client"; import * as React from "react"; import { useCanvasAnimation } from "@/hooks/soft-club/use-canvas-animation"; import { rgba, themeRgb } from "@/lib/soft-club/theme-color"; import { cx } from "@/lib/soft-club/cx"; export interface AsciiLifeProps extends React.HTMLAttributes { label?: string; cell?: number; density?: number; } interface LifeState { grid: Uint8Array; next: Uint8Array; age: Uint8Array; cols: number; rows: number; w: number; h: number; acc: number; seeded: boolean; } export const AsciiLife = React.forwardRef( ({ className, label, cell = 13, density = 0.25, ...props }, ref) => { const hostRef = React.useRef(null); const canvasRef = React.useRef(null); const state = React.useRef({ grid: new Uint8Array(0), next: new Uint8Array(0), age: new Uint8Array(0), cols: 0, rows: 0, w: 0, h: 0, acc: 0, seeded: false }); useCanvasAnimation( canvasRef, hostRef, ({ ctx, dt, height, pointer, width }) => { const st = state.current; const cellH = cell * 1.15; const seed = () => { for (let i = 0; i < st.grid.length; i += 1) { st.grid[i] = Math.random() < density ? 1 : 0; st.age[i] = st.grid[i]; } }; if (!st.seeded || st.w !== width || st.h !== height) { st.cols = Math.max(1, Math.floor(width / cell)); st.rows = Math.max(1, Math.floor(height / cellH)); const size = st.cols * st.rows; st.grid = new Uint8Array(size); st.next = new Uint8Array(size); st.age = new Uint8Array(size); st.w = width; st.h = height; st.seeded = true; st.acc = 0; seed(); } const { cols, rows } = st; const step = () => { let alive = 0; for (let y = 0; y < rows; y += 1) { const up = (y - 1 + rows) % rows; const down = (y + 1) % rows; for (let x = 0; x < cols; x += 1) { const left = (x - 1 + cols) % cols; const right = (x + 1) % cols; const n = (st.grid[up * cols + left] ?? 0) + (st.grid[up * cols + x] ?? 0) + (st.grid[up * cols + right] ?? 0) + (st.grid[y * cols + left] ?? 0) + (st.grid[y * cols + right] ?? 0) + (st.grid[down * cols + left] ?? 0) + (st.grid[down * cols + x] ?? 0) + (st.grid[down * cols + right] ?? 0); const i = y * cols + x; const was = st.grid[i] ?? 0; const live = was === 1 ? n === 2 || n === 3 : n === 3; st.next[i] = live ? 1 : 0; st.age[i] = live ? (was === 1 ? Math.min(255, (st.age[i] ?? 0) + 1) : 1) : 0; if (live) alive += 1; } } const tmp = st.grid; st.grid = st.next; st.next = tmp; if (alive < cols * rows * 0.02) seed(); }; st.acc += dt; while (st.acc >= 0.09) { step(); st.acc -= 0.09; } if (pointer.inside) { const px = Math.floor(pointer.x / cell); const py = Math.floor(pointer.y / cellH); for (let dy = -1; dy <= 1; dy += 1) { for (let dx = -1; dx <= 1; dx += 1) { const cxw = (px + dx + cols) % cols; const cyw = (py + dy + rows) % rows; const i = cyw * cols + cxw; st.grid[i] = 1; if ((st.age[i] ?? 0) === 0) st.age[i] = 1; } } } const primary = themeRgb(hostRef.current, "--sc-theme-rgb-primary", [142, 255, 173]); const text = themeRgb(hostRef.current, "--sc-theme-rgb-text", [247, 255, 248]); ctx.clearRect(0, 0, width, height); ctx.font = `${cell}px var(--sc-font-mono, monospace)`; ctx.textBaseline = "top"; for (let y = 0; y < rows; y += 1) { for (let x = 0; x < cols; x += 1) { const i = y * cols + x; if ((st.grid[i] ?? 0) !== 1) continue; if ((st.age[i] ?? 0) === 1) { ctx.fillStyle = rgba(text, 0.95); ctx.fillText("+", x * cell, y * cellH); } else { ctx.fillStyle = rgba(primary, 0.85); ctx.fillText("@", x * cell, y * cellH); } } } }, { reactive: true } ); const setRef = (node: HTMLDivElement | null) => { hostRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }; return (
); } ); AsciiLife.displayName = "AsciiLife"; ``` ### AsciiPlasma (`ascii-plasma`) Soft Club AsciiPlasma component. - Registry item: https://cobanov.github.io/soft-club-ui/r/ascii-plasma.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/ascii-plasma.json` - npm dependencies: none - Registry dependencies: base, theme-color, use-canvas-animation - Installs to: `components/soft-club/ascii-plasma.tsx` ```tsx "use client"; import * as React from "react"; import { useCanvasAnimation } from "@/hooks/soft-club/use-canvas-animation"; import { rgba, themeRgb } from "@/lib/soft-club/theme-color"; import { cx } from "@/lib/soft-club/cx"; export interface AsciiPlasmaProps extends React.HTMLAttributes { label?: string; cell?: number; speed?: number; } const RAMP = " .:-=+*#%@"; function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; } function mix(c1: [number, number, number], c2: [number, number, number], t: number): [number, number, number] { return [lerp(c1[0], c2[0], t), lerp(c1[1], c2[1], t), lerp(c1[2], c2[2], t)]; } export const AsciiPlasma = React.forwardRef( ({ className, label, cell = 11, speed = 1, ...props }, ref) => { const hostRef = React.useRef(null); const canvasRef = React.useRef(null); const state = React.useRef({ w: 0, h: 0 }); useCanvasAnimation( canvasRef, hostRef, ({ ctx, height, pointer, time, width }) => { const st = state.current; if (st.w !== width || st.h !== height) { st.w = width; st.h = height; } const primary = themeRgb(hostRef.current, "--sc-theme-rgb-primary", [142, 255, 173]); const secondary = themeRgb(hostRef.current, "--sc-theme-rgb-secondary", [139, 184, 215]); const text = themeRgb(hostRef.current, "--sc-theme-rgb-text", [247, 255, 248]); const cellH = cell * 1.15; const cols = Math.floor(width / cell); const rows = Math.floor(height / cellH); const cx0 = width / 2; const cy0 = height / 2; const t = time * speed; ctx.clearRect(0, 0, width, height); ctx.font = `${cell}px var(--sc-font-mono, monospace)`; ctx.textBaseline = "top"; const rampMax = RAMP.length - 1; for (let row = 0; row < rows; row += 1) { const y = row * cellH; for (let col = 0; col < cols; col += 1) { const x = col * cell; let value = Math.sin(x * 0.04 + t) + Math.sin(y * 0.05 - t * 0.8) + Math.sin((x + y) * 0.03 + t * 1.3) + Math.sin(Math.hypot(x - cx0, y - cy0) * 0.05 - t * 1.5); if (pointer.inside) { const pd = Math.hypot(x - pointer.x, y - pointer.y); value += Math.sin(pd * 0.06 - t * 2.2) * Math.max(0, 1 - pd / 220) * 1.6; } const v01 = Math.min(1, Math.max(0, (value + 4) / 8)); const ch = RAMP[Math.min(rampMax, Math.floor(v01 * rampMax))] ?? " "; if (ch === " ") continue; const tone = v01 < 0.5 ? mix(primary, secondary, v01 * 2) : mix(secondary, text, (v01 - 0.5) * 2); ctx.fillStyle = rgba(tone, 0.35 + v01 * 0.6); ctx.fillText(ch, x, y); } } }, { fps: 30, reactive: true } ); const setRef = (node: HTMLDivElement | null) => { hostRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }; return (
); } ); AsciiPlasma.displayName = "AsciiPlasma"; ``` ### AsciiSnapshot (`ascii-snapshot`) Framed share card: an animated ASCII portrait over a headline, copy, range toggle, and share actions. - Registry item: https://cobanov.github.io/soft-club-ui/r/ascii-snapshot.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/ascii-snapshot.json` - npm dependencies: none - Registry dependencies: base - Installs to: `components/soft-club/ascii-snapshot.tsx` ```tsx "use client"; import * as React from "react"; import { cx } from "@/lib/soft-club/cx"; export interface AsciiSnapshotProps extends React.HTMLAttributes { /** Draw the four registration corner brackets around the card. */ framed?: boolean; } /** * A shareable snapshot card framed by registration corner marks: an ASCII (or * any live) media panel up top, a serif-scaled headline with an optional corner * action, descriptive copy, and a footer that pairs a range toggle with share * actions. Compose the media with for the animated code-portrait look. */ const AsciiSnapshotRoot = React.forwardRef( ({ children, className, framed = true, ...props }, ref) => (
{framed ? (
) ); AsciiSnapshotRoot.displayName = "AsciiSnapshot"; export const AsciiSnapshotMedia = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); AsciiSnapshotMedia.displayName = "AsciiSnapshotMedia"; export const AsciiSnapshotBody = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); AsciiSnapshotBody.displayName = "AsciiSnapshotBody"; export interface AsciiSnapshotHeadlineProps extends React.HTMLAttributes { /** Right-aligned slot, typically a small square icon button. */ action?: React.ReactNode; } export const AsciiSnapshotHeadline = React.forwardRef( ({ action, children, className, ...props }, ref) => (

{children}

{action ?
{action}
: null}
) ); AsciiSnapshotHeadline.displayName = "AsciiSnapshotHeadline"; export const AsciiSnapshotDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

)); AsciiSnapshotDescription.displayName = "AsciiSnapshotDescription"; export const AsciiSnapshotFooter = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

)); AsciiSnapshotFooter.displayName = "AsciiSnapshotFooter"; export const AsciiSnapshotActions = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); AsciiSnapshotActions.displayName = "AsciiSnapshotActions"; export const AsciiSnapshot = Object.assign(AsciiSnapshotRoot, { Actions: AsciiSnapshotActions, Body: AsciiSnapshotBody, Description: AsciiSnapshotDescription, Footer: AsciiSnapshotFooter, Headline: AsciiSnapshotHeadline, Media: AsciiSnapshotMedia }); ``` ### AsciiStars (`ascii-stars`) Soft Club AsciiStars component. - Registry item: https://cobanov.github.io/soft-club-ui/r/ascii-stars.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/ascii-stars.json` - npm dependencies: none - Registry dependencies: base, theme-color, use-canvas-animation - Installs to: `components/soft-club/ascii-stars.tsx` ```tsx "use client"; import * as React from "react"; import { useCanvasAnimation } from "@/hooks/soft-club/use-canvas-animation"; import { rgba, themeRgb } from "@/lib/soft-club/theme-color"; import { cx } from "@/lib/soft-club/cx"; export interface AsciiStarsProps extends React.HTMLAttributes { count?: number; label?: string; speed?: number; depth?: number; } interface Star { x: number; y: number; z: number; } const GLYPHS = " .:+*oO#@"; export const AsciiStars = React.forwardRef( ({ className, count = 140, depth = 1.4, label, speed = 0.9, ...props }, ref) => { const hostRef = React.useRef(null); const canvasRef = React.useRef(null); const state = React.useRef({ stars: [] as Star[], seeded: false, w: 0, h: 0, cx: 0, cy: 0 }); useCanvasAnimation( canvasRef, hostRef, ({ ctx, dt, height, pointer, width }) => { const st = state.current; const spread = depth; const spawn = (s: Star) => { s.x = (Math.random() * 2 - 1) * spread; s.y = (Math.random() * 2 - 1) * spread; s.z = Math.random() * depth + 0.05; }; if (!st.seeded || st.w !== width || st.h !== height) { st.w = width; st.h = height; st.seeded = true; st.cx = width / 2; st.cy = height / 2; st.stars = Array.from({ length: count }, () => { const s: Star = { x: 0, y: 0, z: 0 }; spawn(s); return s; }); } const primary = themeRgb(hostRef.current, "--sc-theme-rgb-primary", [142, 255, 173]); const text = themeRgb(hostRef.current, "--sc-theme-rgb-text", [247, 255, 248]); const scale = Math.min(width, height) * 0.9; const targetX = pointer.inside ? width - pointer.x : width / 2; const targetY = pointer.inside ? height - pointer.y : height / 2; st.cx += (targetX - st.cx) * Math.min(1, dt * 2.4); st.cy += (targetY - st.cy) * Math.min(1, dt * 2.4); ctx.fillStyle = rgba([0, 0, 0], 0.42); ctx.fillRect(0, 0, width, height); const cell = 12; ctx.font = `${cell}px var(--sc-font-mono, monospace)`; ctx.textBaseline = "top"; for (const s of st.stars) { s.z -= speed * dt * (0.3 + s.z); const sx = st.cx + (s.x / s.z) * scale; const sy = st.cy + (s.y / s.z) * scale; if (s.z <= 0.05 || sx < 0 || sx > width || sy < 0 || sy > height) { spawn(s); continue; } const b = Math.max(0, Math.min(1, 1 - s.z / depth)); const idx = Math.min(GLYPHS.length - 1, Math.floor(b * (GLYPHS.length - 1))); const glyph = GLYPHS[idx] ?? "."; const r = primary[0] + (text[0] - primary[0]) * b; const g = primary[1] + (text[1] - primary[1]) * b; const bl = primary[2] + (text[2] - primary[2]) * b; ctx.fillStyle = rgba([r, g, bl], 0.25 + b * 0.75); ctx.fillText(glyph, sx, sy); } }, { reactive: true } ); const setRef = (node: HTMLDivElement | null) => { hostRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }; return (
); } ); AsciiStars.displayName = "AsciiStars"; ``` ### AsciiTunnel (`ascii-tunnel`) Soft Club AsciiTunnel component. - Registry item: https://cobanov.github.io/soft-club-ui/r/ascii-tunnel.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/ascii-tunnel.json` - npm dependencies: none - Registry dependencies: base, theme-color, use-canvas-animation - Installs to: `components/soft-club/ascii-tunnel.tsx` ```tsx "use client"; import * as React from "react"; import { useCanvasAnimation } from "@/hooks/soft-club/use-canvas-animation"; import { rgba, themeRgb } from "@/lib/soft-club/theme-color"; import { cx } from "@/lib/soft-club/cx"; const RAMP = " .:-=+*#%@"; export interface AsciiTunnelProps extends React.HTMLAttributes { label?: string; cell?: number; speed?: number; twist?: number; } export const AsciiTunnel = React.forwardRef( ({ className, label, cell = 11, speed = 1, twist = 8, ...props }, ref) => { const hostRef = React.useRef(null); const canvasRef = React.useRef(null); const state = React.useRef({ ccol: 0, crow: 0, w: 0, h: 0, seeded: false }); useCanvasAnimation( canvasRef, hostRef, ({ ctx, height, pointer, time, width }) => { const st = state.current; const cellH = cell * 1.15; const cols = Math.max(1, Math.floor(width / cell)); const rows = Math.max(1, Math.floor(height / cellH)); if (!st.seeded || st.w !== width || st.h !== height) { st.w = width; st.h = height; st.seeded = true; st.ccol = cols / 2; st.crow = rows / 2; } // Steer the vanishing point toward the pointer cell. const targetCol = pointer.inside ? pointer.x / cell : cols / 2; const targetRow = pointer.inside ? pointer.y / cellH : rows / 2; st.ccol += (targetCol - st.ccol) * 0.08; st.crow += (targetRow - st.crow) * 0.08; const primary = themeRgb(hostRef.current, "--sc-theme-rgb-primary", [142, 255, 173]); const text = themeRgb(hostRef.current, "--sc-theme-rgb-text", [247, 255, 248]); const t = time * 0.5 * speed; // Work in normalized screen space so rings stay concentric and fill the // (wide, short) panel instead of collapsing into vertical bands. const halfW = width * 0.5; const halfH = height * 0.5; const cxPx = st.ccol * cell; const cyPx = st.crow * cellH; ctx.clearRect(0, 0, width, height); ctx.font = `${cell}px var(--sc-font-mono, monospace)`; ctx.textBaseline = "top"; for (let row = 0; row < rows; row += 1) { const py = row * cellH + cellH * 0.5; const ny = (py - cyPx) / halfH; for (let col = 0; col < cols; col += 1) { const px = col * cell + cell * 0.5; const nx = (px - cxPx) / halfW; const radius = Math.hypot(nx, ny) + 1e-3; const angle = Math.atan2(ny, nx); // Depth scrolls inward; rings bunch up toward the vanishing point. const depth = 0.7 / radius + t; const rings = 0.5 + 0.5 * Math.sin(depth * Math.PI * 2); const spokes = 0.65 + 0.35 * Math.sin(angle * twist - t * 2); const edge = Math.max(0, Math.min(1, 1.35 - radius)); const core = Math.exp(-radius * radius * 7); const value = rings * spokes * edge + core * 0.6; const v01 = value < 0 ? 0 : value > 1 ? 1 : value; const ri = Math.min(RAMP.length - 1, Math.floor(v01 * RAMP.length)); const char = RAMP[ri] ?? " "; if (char === " ") continue; const r = primary[0] + (text[0] - primary[0]) * v01; const g = primary[1] + (text[1] - primary[1]) * v01; const b = primary[2] + (text[2] - primary[2]) * v01; ctx.fillStyle = rgba([r, g, b], 0.25 + 0.7 * v01); ctx.fillText(char, col * cell, row * cellH); } } }, { reactive: true } ); const setRef = (node: HTMLDivElement | null) => { hostRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }; return (
); } ); AsciiTunnel.displayName = "AsciiTunnel"; ``` ### AspectRatio (`aspect-ratio`) Locks content to a fixed width-to-height ratio. - Registry item: https://cobanov.github.io/soft-club-ui/r/aspect-ratio.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/aspect-ratio.json` - npm dependencies: none - Registry dependencies: base - Installs to: `components/soft-club/aspect-ratio.tsx` ```tsx "use client"; import * as React from "react"; import { cx } from "@/lib/soft-club/cx"; export interface AspectRatioProps extends React.HTMLAttributes { /** Width-to-height ratio, e.g. 16 / 9. Defaults to 1. */ ratio?: number; } /** Constrains its content to a fixed width-to-height ratio. */ export const AspectRatio = React.forwardRef( ({ className, ratio = 1, style, ...props }, ref) => (
) ); AspectRatio.displayName = "AspectRatio"; ``` ### AuroraField (`aurora-field`) Soft ambient field used inside grid and hero surfaces. - Registry item: https://cobanov.github.io/soft-club-ui/r/aurora-field.json - Install: `npx shadcn@latest add https://cobanov.github.io/soft-club-ui/r/aurora-field.json` - npm dependencies: none - Registry dependencies: base - Installs to: `components/soft-club/aurora-field.tsx` ```tsx "use client"; import * as React from "react"; import { cx } from "@/lib/soft-club/cx"; export interface AuroraFieldProps extends React.HTMLAttributes { intensity?: "low" | "medium" | "high"; } export const AuroraField = React.forwardRef( ({ className, intensity = "medium", ...props }, ref) => (