From 7605e531b809a584da1fc3c1121b6ea29011552f Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Thu, 8 Oct 2026 16:09:07 +0300 Subject: [PATCH 1/2] feat(site): shared design system for the landing page and studio Tokens derived from the pack's custom properties and one set of buttons, segmented controls, chips, fields and popups in docs/web/app/ui. Base UI supplies the toggle group, select and popover behaviour; the styling stays plain CSS so a pack change still restyles every control. DESIGN.md records the system. Signed-off-by: NovusEdge --- .docket/ledger.jsonl | 1 + CHANGELOG.md | 4 + docs/web/DESIGN.md | 241 ++++++++++++++++++++++++++ docs/web/app/landing/ClawdSection.tsx | 7 +- docs/web/app/landing/Hero.tsx | 12 +- docs/web/app/landing/MakerSection.tsx | 9 +- docs/web/app/landing/Nav.tsx | 11 +- docs/web/app/landing/PackContext.tsx | 6 +- docs/web/app/landing/PacksSection.tsx | 2 +- docs/web/app/landing/landing.css | 67 ++++--- docs/web/app/landing/terminal.css | 2 +- docs/web/app/studio/Actions.tsx | 71 +++++--- docs/web/app/studio/ColorsSection.tsx | 2 +- docs/web/app/studio/Controls.tsx | 3 +- docs/web/app/studio/PetSection.tsx | 5 +- docs/web/app/studio/Studio.tsx | 20 +-- docs/web/app/studio/controls.css | 43 +---- docs/web/app/studio/studio.css | 33 ++-- docs/web/app/studio/ui.tsx | 19 +- docs/web/app/ui/Segmented.tsx | 17 ++ docs/web/app/ui/Select.tsx | 31 ++++ docs/web/app/ui/icons.tsx | 15 ++ docs/web/app/ui/portal.tsx | 7 + docs/web/app/ui/system.css | 109 ++++++++++++ docs/web/package.json | 5 +- docs/web/pnpm-lock.yaml | 73 ++++++++ 26 files changed, 649 insertions(+), 166 deletions(-) create mode 100644 docs/web/DESIGN.md create mode 100644 docs/web/app/ui/Segmented.tsx create mode 100644 docs/web/app/ui/Select.tsx create mode 100644 docs/web/app/ui/icons.tsx create mode 100644 docs/web/app/ui/portal.tsx create mode 100644 docs/web/app/ui/system.css diff --git a/.docket/ledger.jsonl b/.docket/ledger.jsonl index e9316f2b..1a5a77ce 100644 --- a/.docket/ledger.jsonl +++ b/.docket/ledger.jsonl @@ -132,3 +132,4 @@ {"schema":3,"kind":"question","id":"q14","text":"Does cmd.exe /c start leave the config TUI holding process.run's pipes on Windows, so /glowup config waits 30 s and prints the fallback while the window is open?","state":"open","ts":"2026-10-08T10:46:42+00:00","author":"claude-code","session":"","branch":"feat/config-tui","scope":["hooks/launch.ts"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false} {"schema":3,"kind":"claim","id":"c15","text":"The config TUI's Linux launch works live: tmux window, Konsole, $TERMINAL=xterm, hot reload, /clear mid-run, q and Esc","state":"accepted","ts":"2026-10-08T10:46:43+00:00","author":"claude-code","session":"","branch":"feat/config-tui","scope":["hooks/launch.ts","hooks/register.tsx"],"rationale":"macOS and Windows launches are unit-tested only","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"Task 11 live check 2026-10-08, task-11-report.md"}],"revisit":"","cost_if_wrong":"","pinned":false} {"schema":3,"kind":"decision","id":"d100","text":"The config TUI draws the session's built-in pet playing its idle animation from frames baked into packs.json","state":"adopted","ts":"2026-10-08T12:30:13+00:00","author":"claude-code","session":"","branch":"fix/config-tui-pets","scope":["hooks/packexport.ts","installer/internal/tui/clawd.go","installer/internal/configtui/view.go"],"rationale":"The installer already embeds packs.json; built-ins cover the options list, and it adds about 350 KB to a 5.7 MB binary. The egg shows its uncracked stage because the installer has no session store.","supports":[["d96"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"User pets appear in the config TUI's pet options, or the egg's stage matters there","cost_if_wrong":"","pinned":false,"choice":"packexport.ts exports every BUILTIN_SHEETS pet's idle frames as half-block rows; the TUI picks the frame by tick time, holds frame 0 under reduced motion, and widens the column to the pet","alternatives":["Have the mod write the pet's frames into state.json each run, which would also cover user pets and the egg's crack stage"],"decided_by":""} +{"schema":3,"kind":"decision","id":"d101","text":"The landing page and studio share one token layer and control set in docs/web/app/ui, with Base UI supplying behavior for toggle groups, selects and popovers","state":"adopted","ts":"2026-10-08T13:02:49+00:00","author":"claude-code","session":"","branch":"feat/site-design-system","scope":["docs/web/app/ui/","docs/web/app/landing/","docs/web/app/studio/"],"rationale":"Both pages are plain CSS on runtime pack variables; shadcn's components are Tailwind-classed and would force those pages onto utility classes. Base UI is unstyled, so the existing CSS styles it and live pack theming keeps working.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"the landing or studio pages move to Tailwind utilities","cost_if_wrong":"","pinned":false,"choice":"app/ui/system.css holds tokens derived from the pack's custom properties plus .btn/.seg/.chip/.field/.pop classes; Segmented, Select and the studio share popover wrap @base-ui/react and portal into the .landing root","alternatives":["shadcn/ui components themed through their CSS variables","Tokens and CSS only, keeping the hand-built popover and native selects"],"decided_by":"owner"} diff --git a/CHANGELOG.md b/CHANGELOG.md index b576a41f..e63cc439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- The website's landing page and studio share one set of buttons, toggles, selects and popovers, and they all follow the pack you pick. The studio's selects open a themed list instead of the browser's own, and its Send to my Claude popover takes keyboard focus when it opens. + ## [0.12.1] - 2026-10-08 ### Fixed diff --git a/docs/web/DESIGN.md b/docs/web/DESIGN.md new file mode 100644 index 00000000..894a83fb --- /dev/null +++ b/docs/web/DESIGN.md @@ -0,0 +1,241 @@ +--- +name: glowup site +description: Landing page and studio for glowup, styled live by whichever glowup pack is active. +colors: + # Runtime values. Every color is a custom property set on the .landing root by lookVars() from the active pack; + # the hex values here are the default pack (classic) and exist only as a reference rendering. + accent: "#d77757" + bg: "#1f1f24" + panel: "#1f1f24" + text: "#e4e4e7" + dim: "#8b8b94" + faint: "#3a3a42" + read: "#7dc4e4" + edit: "#e5b567" + shell: "#4eba65" + agent: "#b39ddb" + pass: "#4eba65" + fail: "#ff6b80" +typography: + display: + fontFamily: "Geist, system-ui, sans-serif" + fontSize: "clamp(44px, 7.4vw, 92px)" + fontWeight: 700 + lineHeight: 1 + letterSpacing: "-0.045em" + headline: + fontFamily: "Geist, system-ui, sans-serif" + fontSize: "clamp(30px, 4vw, 44px)" + fontWeight: 700 + lineHeight: 1.05 + letterSpacing: "-0.035em" + body: + fontFamily: "Geist, system-ui, sans-serif" + fontSize: "17px" + fontWeight: 400 + lineHeight: 1.5 + label: + fontFamily: "Geist, system-ui, sans-serif" + fontSize: "13.5px" + fontWeight: 600 + lineHeight: 1 + letterSpacing: "-0.005em" + code: + fontFamily: "'JetBrains Mono', ui-monospace, monospace" + fontSize: "13px" + fontWeight: 400 + logo: + fontFamily: "'Pixelify Sans', monospace" + fontSize: "21px" + fontWeight: 700 +rounded: + sm: "6px" + md: "8px" + lg: "12px" + full: "999px" +spacing: + h-sm: "30px" + h-md: "36px" + h-lg: "44px" +components: + button-primary: + backgroundColor: "{colors.accent}" + textColor: "{colors.bg}" + typography: "{typography.label}" + rounded: "{rounded.md}" + padding: "0 14px" + height: "{spacing.h-md}" + button-secondary: + backgroundColor: "{colors.panel}" + textColor: "{colors.text}" + rounded: "{rounded.md}" + padding: "0 14px" + height: "{spacing.h-md}" + button-ghost: + textColor: "{colors.dim}" + rounded: "{rounded.md}" + height: "{spacing.h-md}" + button-link: + textColor: "{colors.accent}" + button-sm: + rounded: "{rounded.sm}" + padding: "0 10px" + height: "{spacing.h-sm}" + segmented-item: + textColor: "{colors.dim}" + typography: "{typography.code}" + rounded: "{rounded.sm}" + padding: "0 12px" + height: "{spacing.h-sm}" + segmented-item-pressed: + backgroundColor: "{colors.accent}" + textColor: "{colors.bg}" + chip: + textColor: "{colors.text}" + typography: "{typography.code}" + rounded: "{rounded.full}" + padding: "0 12px 0 6px" + height: "{spacing.h-sm}" + field: + backgroundColor: "{colors.panel}" + textColor: "{colors.text}" + typography: "{typography.code}" + rounded: "{rounded.md}" + padding: "0 10px" + height: "{spacing.h-md}" + menu-item: + typography: "{typography.code}" + rounded: "{rounded.sm}" + height: "32px" +--- + +# Design System: glowup site + +Scope: the landing page (`app/landing`) and the studio (`app/studio`), both rendered inside a `.landing` root. Tokens and shared controls live in `app/ui/system.css`; page layout lives in `app/landing/landing.css`, `app/studio/studio.css` and `app/studio/controls.css`. The docs pages are fumadocs-ui and take only the color tokens in `app/app.css`. + +## Overview + +**Creative North Star: "The Pack Is the Palette"** + +The site is a glowup terminal turned outward. It has no fixed brand colors: `PackContext` resolves the active pack and `lookVars()` writes `--bg`, `--panel`, `--text`, `--dim`, `--faint`, `--accent` and the role colors (`--read`, `--edit`, `--shell`, `--agent`, `--pass`, `--fail`, plus `--addBg`, `--delBg`, `--sel`) onto the `.landing` root. Every other token is derived from those with `color-mix()`, so a pack change restyles every control, popup and page surface live. + +The build is plain CSS on runtime custom properties. Base UI (`@base-ui/react`) supplies behavior and accessibility only: ToggleGroup for segmented controls, Select, and Popover. The styling is ours. Shadcn was considered and rejected because its components are Tailwind-classed and these pages are plain CSS on runtime properties. + +**Key Characteristics:** +- Dark, pack-tinted surfaces; one accent per pack carries all emphasis. +- Two type voices: Geist for actions and prose, JetBrains Mono for command arguments and values. +- Flat controls with 1px hairline borders; shadow is reserved for floating layers. +- Base UI behavior, hand-written CSS, no Tailwind on these surfaces. + +## Colors + +The palette is whatever the active pack says it is; the frontmatter shows the default pack (classic) only as a reference rendering. + +### Primary +- **Pack Accent** (`--accent`): the primary button fill, the pressed segment, chip selection, focus ring (2px solid, 2px offset), caret, checkbox/radio/range fill, link text and the menu check mark. Text on accent uses `--on-accent`, which is `--bg`. + +### Neutral +- **Ground** (`--bg`): page background and the text color on accent. +- **Panel** (`--panel`, aliased `--surface`): fields, secondary buttons, popups, the studio sidebar. +- **Text** (`--text`), **Dim** (`--dim`), **Faint** (`--faint`, aliased `--line`): body text, secondary text and placeholders, hairline borders. +- **Derived:** `--line-strong` (dim 55% into faint) for hover borders and unchecked checkboxes; `--tint` (text 7%) and `--tint-strong` (text 12%) for ghost hover and the highlighted menu item; `--accent-soft` (accent 14%) for a selected chip's fill. + +### Role colors +- **Read / Edit / Shell / Agent / Pass / Fail**: the colors glowup paints tool rows and results with in the terminal. On the site they color the terminal mocks; outside them, `--fail` marks invalid input and errors and `--edit` marks warnings and notes. + +### Named Rules +**The No Fixed Color Rule.** No hex value is written into a landing or studio control. A new color is a `color-mix()` of the pack variables or it does not exist. The only fixed colors are the studio color wheel's hue/value gradients and its white thumb, which depict color itself. + +**The Portal Home Rule.** Popups portal into the `.landing` root through `PortalContext`, never into ``; outside the root the pack's custom properties do not reach them. + +## Typography + +**UI Font:** Geist (`--font-ui`, with system-ui) +**Code Font:** JetBrains Mono (`--font-code`, with ui-monospace) +**Logo Font:** Pixelify Sans (`--font-logo`), the wordmark only + +**Character:** a tight, negatively tracked grotesk for what the site says and does, and a monospace for anything the user would type into a terminal. + +### Hierarchy +- **Display** (700, clamp(44px, 7.4vw, 92px), 1, -0.045em): the hero heading only. +- **Headline** (700, clamp(30px, 4vw, 44px), 1.05, -0.035em): section headings. +- **Body** (400, 17px, 1.5, dim, max 640px): section ledes; the hero sub is 19px, the studio runs at 14px. +- **Label** (600, 13.5px, 1): button text; 12.5px at small size. Hints, field labels and error lines are 12.5px Geist in dim. +- **Code** (400, 13px): segment values, chips, fields, menu items, install command, command readouts (12px). + +### Named Rules +**The Mono Means Argument Rule.** Mono (`--font-code`) is for command arguments and values: pack names, outfits, hex codes, commands. Geist (`--font-ui`) is for actions and prose. A segmented control whose options are UI words rather than arguments takes the `ui` variant to switch to Geist. + +## Layout + +The landing page is a single centered column (max 1240px, 20px gutters) under a floating nav (fixed, min(720px, 100% - 24px), blurred translucent `--bg`). Sections are spaced 110px apart (80px under 900px); card grids use `auto-fill` with 180px, 210px or 260px minimums and a 14px gap, falling to two columns under 900px. + +The studio is a fixed full-viewport app at 900px and up: a top bar, a 360px scrolling sidebar of accordion sections on the left and the terminal preview filling the rest. Under 900px it stacks: preview first (capped at 50dvh, scrolling from its end), then the sidebar, with the three actions pinned to the bottom in a 44px-high grid. + +Control heights come from `--h-sm` (30px), `--h-md` (36px) and `--h-lg` (44px). Under `pointer: coarse`, `--h-sm` and `--h-md` rise to 40px and 44px so every control meets a touch target without per-component overrides. Studio checkboxes, swatches and accordion headers hold a 44px minimum below 900px. + +## Elevation & Depth + +Flat at rest. Depth comes from tonal steps (`--bg` under `--panel`) and 1px hairlines, not from shadow. + +### Shadow Vocabulary +- **Popup** (`--shadow-pop`: `0 12px 32px -8px #000b, 0 2px 6px -2px #0006`): Select and Popover panels. +- **Floating nav** (`0 10px 30px -12px #0009`) and **install bar** (`0 8px 24px -12px #0008`): the two landing elements that float over the hero. +- **Terminal window** (`0 30px 80px -24px #000c, 0 0 0 1px #0006`): the big terminal mock, which is presented as an object on the page. + +### Named Rules +**The Float Earns Shadow Rule.** Only something that floats above content (a popup, the nav, a window mock) casts a shadow. Buttons, fields, chips and cards stay flat; their states are border and fill changes. + +## Shapes + +Small, even corners: `--r-sm` (6px) for small buttons, segment items and menu items; `--r-md` (8px) for buttons, fields and popups; `--r-lg` (12px) for the nav, install bar and mini terminal; `--r-full` for chips. Borders are 1px; the accent is the only thing that thickens them (the 2px focus ring). + +**The Nested Radius Rule.** A nested element's radius is the outer radius minus the padding between them. The segmented track is `calc(var(--r-sm) + 3px)` around 3px padding so its items sit at `--r-sm`; the large track is `calc(var(--r-md) + 4px)` around 4px padding. + +## Components + +All shared controls are classes in `app/ui/system.css`, scoped under `.landing`. Motion is `--t-fast` (120ms) for color and border changes and `--t` (220ms) on `--ease` (`cubic-bezier(.16,1,.3,1)`) for popup scale; reduced motion drops button and popup transitions. + +### Buttons (`.btn`) +One shape, four weights. All are `--h-md` high, 8px radius, 600 13.5px Geist, with a 16px icon slot and a 1px press-down on `:active`. Disabled is 45% opacity. +- **Primary** (`.btn-primary`): accent fill, `--on-accent` text, hover mixes 14% text into the accent. The single action a view wants: copy the install commands, open the studio, copy the share link. +- **Secondary** (`.btn-secondary`): panel fill with a hairline border that strengthens on hover and turns accent while its popover is open (`aria-expanded`). Sits beside a primary or stands alone where nothing is primary. +- **Ghost** (`.btn-ghost`): no fill, dim text, tint on hover. Icon and toolbar actions that must not compete with content (dismiss, reorder, remove). +- **Link** (`.btn-link`): accent text with a 45%-accent underline. Inline actions in prose. +- **Modifiers:** `.btn-sm` (`--h-sm`, 6px radius, 12.5px, 14px icon); `.btn-icon` (square, icon only, needs `aria-label`). + +### Segmented control (`Segmented`, `.seg`) +Base UI ToggleGroup. One choice from a short fixed set, always one selected (pressing the current item does not clear it). Items are mono at `--h-sm`; the pressed item takes the accent fill. `size="lg"` raises items to `--h-md`; `ui` switches the items to Geist for options that are words, not arguments. + +### Chip (`.chip`) +A pill that is pressable and also shows something, such as a theme's palette strip. Hairline border; pressed (`aria-pressed`) takes an accent border and `--accent-soft` fill. Use it when the option needs a visual sample; use a segment when it is a bare value. + +### Fields (`.field`, inputs, `Select`) +Panel fill, hairline border, `--r-md`, `--h-md`, 13px mono. Hover strengthens the border; an open Select trigger takes the accent border; `aria-invalid` takes `--fail`. Checkboxes (5px radius) and radios are 18px and fill with the accent; range sliders run a 4px dim track with an accent thumb. `Select` wraps Base UI Select: the trigger is a `.field` with a chevron, the list is a `.menu` inside a `.pop`. + +### Popups (`.pop`, `.menu`) +Panel fill, hairline border, `--r-md`, `--shadow-pop`, at least the anchor's width, scaling in from 97% at the anchor. Menu items are 32px mono rows with an accent check mark in a 14px leading column and `--tint-strong` on highlight. The studio's send popover (`.st-pop`) is a `.pop` holding a command readout. + +### Icons (`app/ui/icons.tsx`) +Authored 16x16 SVGs, 1.5 stroke, round caps and joins, `currentColor`, `aria-hidden`. Shown at 16px in buttons and 14px in small buttons, fields and menus. + +### Mini terminal (`.mini`) and terminal (`.term`) +Product content, not chrome: they render glowup's own output with the active pack's role colors, glyphs and row styles. The `cards` row style's 3px left border in the row's role color reproduces glowup's in-terminal rendering; it is a depiction of the product, not a decoration available to other components. + +## Do's and Don'ts + +### Do: +- **Do** derive every new color from the pack variables with `color-mix()`, and read it through the semantic tokens (`--surface`, `--line`, `--line-strong`, `--tint`, `--accent-soft`). +- **Do** keep one primary button per view; everything else beside it is secondary, ghost or link. +- **Do** set mono only on command arguments and values; actions and prose stay in Geist. +- **Do** size controls from `--h-sm` / `--h-md` / `--h-lg` so `pointer: coarse` lifts them to 40px and 44px. +- **Do** compute a nested radius as the outer radius minus the padding. +- **Do** portal any new popup into the `.landing` root via `usePortal()`. +- **Do** take behavior and accessibility from Base UI and write the styling in plain CSS. + +### Don't: +- **Don't** write a fixed hex color into a landing or studio control. +- **Don't** use a Unicode glyph as an icon; use the authored SVGs in `icons.tsx` or add one in the same 16px, 1.5-stroke style. Glyphs inside the terminal mocks are glowup's output, not icons. +- **Don't** bring in Tailwind-classed component kits (shadcn and the like) for these surfaces. +- **Don't** put a shadow on a resting control or card. +- **Don't** borrow the cards row style's 3px left border as an accent stripe for site components. diff --git a/docs/web/app/landing/ClawdSection.tsx b/docs/web/app/landing/ClawdSection.tsx index 56f2e1c2..628b7fa1 100644 --- a/docs/web/app/landing/ClawdSection.tsx +++ b/docs/web/app/landing/ClawdSection.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { CLAWD_SHEET } from './data.ts' import { Pet } from './Pet.tsx' +import { Segmented } from '../ui/Segmented' const MOODS: [string, string][] = [ ['working', 'Claude is working'], @@ -33,11 +34,7 @@ export function ClawdSection() {
Outfit -
- {OUTFITS.map(o => ( - - ))} -
+
diff --git a/docs/web/app/landing/Hero.tsx b/docs/web/app/landing/Hero.tsx index 3e2b331a..356a832c 100644 --- a/docs/web/app/landing/Hero.tsx +++ b/docs/web/app/landing/Hero.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react' import { Pet } from './Pet.tsx' import { PACK_NAMES } from './look.ts' import { usePack } from './PackContext.tsx' +import { CheckIcon, CopyIcon } from '../ui/icons' +import { Segmented } from '../ui/Segmented' const INSTALL = '/plugin marketplace add NovusEdge/glowup\n/plugin install glowup@glowup' @@ -28,14 +30,12 @@ export function Hero() {
/plugin marketplace add NovusEdge/glowup
/plugin install glowup@glowup
- +
-
- {PACK_NAMES.map(n => ( - - ))} -
+ ) } diff --git a/docs/web/app/landing/MakerSection.tsx b/docs/web/app/landing/MakerSection.tsx index e2a8b4ce..42f9d845 100644 --- a/docs/web/app/landing/MakerSection.tsx +++ b/docs/web/app/landing/MakerSection.tsx @@ -1,3 +1,4 @@ +import { ArrowRightIcon } from '../ui/icons' import { MiniTerminal } from './MiniTerminal.tsx' import { usePack } from './PackContext.tsx' @@ -5,11 +6,13 @@ export function MakerSection() { const { look } = usePack() return (
-

Make your own

-

Pick every color, the rows, the spinner and your layout in the studio, with a live preview. Then copy one command into Claude Code.

+
+

Make your own

+

Pick every color, the rows, the spinner and your layout in the studio, with a live preview. Then copy one command into Claude Code.

+ Open the studio +
-

Open the studio →

) diff --git a/docs/web/app/landing/Nav.tsx b/docs/web/app/landing/Nav.tsx index b6249941..0c960952 100644 --- a/docs/web/app/landing/Nav.tsx +++ b/docs/web/app/landing/Nav.tsx @@ -1,14 +1,15 @@ +import { StarIcon } from '../ui/icons' import './landing.css' export function Nav() { return ( ) } diff --git a/docs/web/app/landing/PackContext.tsx b/docs/web/app/landing/PackContext.tsx index fe955f4d..0a2aa066 100644 --- a/docs/web/app/landing/PackContext.tsx +++ b/docs/web/app/landing/PackContext.tsx @@ -1,6 +1,7 @@ import { createContext, useCallback, useContext, useMemo, useState } from 'react' import type { Look } from './data.ts' import { lookVars, packLook } from './look.ts' +import { PortalContext } from '../ui/portal' export type PackState = { pack: string; theme?: string; look: Look; setPack(name: string): void; setTheme(name?: string): void; hop: number } @@ -13,9 +14,12 @@ export function PackProvider(props: { children: React.ReactNode; initial?: strin const look = useMemo(() => props.look ?? packLook(pack, theme), [props.look, pack, theme]) const setPack = useCallback((name: string) => { setPackName(name); setTheme(undefined); setHop(h => h + 1) }, []) const value = useMemo(() => ({ pack, theme, look, setPack, setTheme, hop }), [pack, theme, look, setPack, hop]) + const [root, setRoot] = useState(null) return ( -
{props.children}
+ +
{props.children}
+
) } diff --git a/docs/web/app/landing/PacksSection.tsx b/docs/web/app/landing/PacksSection.tsx index 9b387d6d..e53b8df9 100644 --- a/docs/web/app/landing/PacksSection.tsx +++ b/docs/web/app/landing/PacksSection.tsx @@ -24,7 +24,7 @@ export function PacksSection() {
{THEME_NAMES.map(n => ( - diff --git a/docs/web/app/landing/landing.css b/docs/web/app/landing/landing.css index 91ac5ffa..ed086787 100644 --- a/docs/web/app/landing/landing.css +++ b/docs/web/app/landing/landing.css @@ -1,15 +1,17 @@ -.landing{background:var(--bg);color:var(--text);position:relative;font-family:Geist,system-ui,sans-serif;-webkit-font-smoothing:antialiased;transition:background .4s} +@import '../ui/system.css'; + +.landing{background:var(--bg);color:var(--text);position:relative;font-family:var(--font-ui);-webkit-font-smoothing:antialiased;transition:background .4s} .landing *,.landing *::before,.landing *::after{box-sizing:border-box} .landing button{font:inherit;color:inherit;background:none;border:0;cursor:pointer} .landing a{color:inherit;text-decoration:none} .landing .wrap{max-width:1240px;margin:0 auto;padding:0 20px;position:relative;z-index:1} -.landing nav.float{position:fixed;z-index:40;top:14px;left:50%;transform:translateX(-50%);width:min(720px,calc(100% - 24px));display:flex;align-items:center;gap:22px;padding:8px 8px 8px 18px;font-size:14px;color:var(--dim);border-radius:14px;border:1px solid color-mix(in srgb,var(--faint) 80%,transparent);background:color-mix(in srgb,var(--bg) 72%,transparent);-webkit-backdrop-filter:blur(14px) saturate(1.4);backdrop-filter:blur(14px) saturate(1.4);box-shadow:0 10px 30px -12px #0009;transition:background .4s} -.landing nav .logo{margin-right:auto;color:var(--text);font:700 21px 'Pixelify Sans',monospace} +.landing nav.float{position:fixed;z-index:40;top:14px;left:50%;transform:translateX(-50%);width:min(720px,calc(100% - 24px));display:flex;align-items:center;gap:4px;padding:6px 6px 6px 16px;font-size:14px;color:var(--dim);border-radius:var(--r-lg);border:1px solid color-mix(in srgb,var(--line) 80%,transparent);background:color-mix(in srgb,var(--bg) 72%,transparent);-webkit-backdrop-filter:blur(14px) saturate(1.4);backdrop-filter:blur(14px) saturate(1.4);box-shadow:0 10px 30px -12px #0009;transition:background .4s} +.landing nav .logo{margin-right:auto;color:var(--text);font:700 21px var(--font-logo)} .landing nav .logo span{color:var(--accent)} -.landing nav a:hover{color:var(--text)} -.landing nav .gh{padding:7px 12px;border-radius:9px;color:var(--bg);background:var(--text);font-weight:600} -.landing nav .gh:hover{color:var(--bg)} +.landing nav .nav-link{display:inline-flex;align-items:center;height:var(--h-sm);padding:0 10px;border-radius:var(--r-sm);transition:color var(--t-fast),background-color var(--t-fast)} +.landing nav .nav-link:hover{color:var(--text);background:var(--tint)} +.landing nav .btn{margin-left:6px} .landing section.hero{text-align:center;padding:110px 0 36px} .landing .hero canvas{display:block;margin:0 auto -4px;cursor:pointer} @@ -17,50 +19,43 @@ .landing h1 span{color:var(--accent);transition:color .4s} .landing .sub{font-size:19px;color:var(--dim);max-width:600px;margin:20px auto 28px;line-height:1.5;text-shadow:0 2px 24px var(--bg),0 0 2px var(--bg)} .landing .row2{display:flex;gap:14px;justify-content:center;align-items:center;flex-wrap:wrap} -.landing .install{display:inline-flex;align-items:center;gap:14px;max-width:100%;font:13px 'JetBrains Mono',monospace;border-radius:10px;padding:10px 10px 10px 16px;text-align:left;background:var(--panel);border:1px solid var(--faint);color:var(--text)} +.landing .install{display:inline-flex;align-items:center;gap:16px;max-width:100%;font:13px var(--font-code);border-radius:var(--r-lg);padding:8px 8px 8px 16px;text-align:left;background:var(--surface);border:1px solid var(--line);color:var(--text);box-shadow:0 8px 24px -12px #0008} .landing .install code{min-width:0;line-height:1.7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.landing .install button{font:600 12px Geist,sans-serif;padding:6px 12px;border-radius:7px;background:var(--text);color:var(--bg)} -.landing .seg{display:inline-flex;flex-wrap:wrap;justify-content:center;max-width:100%;gap:4px;padding:4px;border-radius:12px;border:1px solid var(--faint);background:color-mix(in srgb,var(--panel) 80%,transparent);margin-top:28px} -.landing .seg button{font:600 13px 'JetBrains Mono',monospace;padding:7px 16px;border-radius:9px;color:var(--dim)} -.landing .seg button[aria-pressed=true]{background:var(--accent);color:var(--bg)} +.landing .install code span{color:var(--dim);user-select:none} +.landing .hero .seg{justify-content:center;margin-top:28px} .landing .sec{padding:110px 0 0} -.landing .sec h2{font:700 clamp(30px,4vw,44px)/1.05 Geist,sans-serif;letter-spacing:-.035em} +.landing .sec h2{font:700 clamp(30px,4vw,44px)/1.05 var(--font-ui);letter-spacing:-.035em} .landing .sec .lede{color:var(--dim);font-size:17px;margin:12px 0 30px;max-width:640px;line-height:1.5} .landing .sec .lede.small{font-size:15px;margin-top:18px} -.landing .sec code{font:13.5px 'JetBrains Mono',monospace;color:var(--accent);background:var(--panel);padding:2px 6px;border-radius:5px} -.landing .card{background:color-mix(in srgb,var(--bg) 82%,transparent);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);border:1px solid var(--faint);border-radius:14px;transition:border-color .2s,background .4s} +.landing .sec code{font:13.5px var(--font-code);color:var(--accent);background:var(--panel);padding:2px 6px;border-radius:var(--r-sm)} +.landing .card{background:color-mix(in srgb,var(--bg) 82%,transparent);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);border:1px solid var(--line);border-radius:var(--r-lg);transition:border-color .2s,background .4s} .landing .moods{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px} .landing .mood{padding:18px 16px 14px;display:flex;flex-direction:column;align-items:center;gap:12px} -.landing .mood b{font:600 14px Geist,sans-serif} -.landing .mood span{font:12px 'JetBrains Mono',monospace;color:var(--dim);margin-top:-8px} -.landing .dress{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin-top:18px;font-size:14px;color:var(--dim)} -.landing .seg2{display:inline-flex;gap:3px;padding:3px;border-radius:10px;border:1px solid var(--faint)} -.landing .seg2 button{font:500 13px 'JetBrains Mono',monospace;padding:5px 12px;border-radius:7px;color:var(--dim)} -.landing .seg2 button[aria-pressed=true]{background:var(--accent);color:var(--bg)} -.landing .dress label{display:flex;gap:6px;align-items:center;font:13px 'JetBrains Mono',monospace} +.landing .mood b{font:600 14px var(--font-ui)} +.landing .mood span{font:12px var(--font-code);color:var(--dim);margin-top:-8px} +.landing .dress{display:flex;gap:10px 14px;align-items:center;flex-wrap:wrap;margin-top:18px;font-size:14px;color:var(--dim)} +.landing .dress label{display:flex;gap:8px;align-items:center;min-height:var(--h-sm);font:13px var(--font-code);color:var(--text);cursor:pointer} .landing .spins{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:14px} .landing .spinc{padding:22px 18px 16px;display:flex;flex-direction:column;gap:14px} .landing .spinc .art{height:96px;display:flex;align-items:center;justify-content:center} -.landing .spinc b{font:600 14px Geist,sans-serif} -.landing .spinc span.cmd{font:12px 'JetBrains Mono',monospace;color:var(--dim)} +.landing .spinc b{font:600 14px var(--font-ui)} +.landing .spinc span.cmd{font:12px var(--font-code);color:var(--dim)} .landing .packs{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px} .landing .packc{padding:0;overflow:hidden;cursor:pointer;text-align:left;display:flex;flex-direction:column;justify-content:space-between} .landing .packc .mini{flex:1;border:0;border-radius:0} .landing .packc:hover,.landing .packc[aria-pressed=true]{border-color:var(--accent)} -.landing .packc .cap{padding:12px 16px;display:flex;justify-content:space-between;font:13px 'JetBrains Mono',monospace;color:var(--dim);border-top:1px solid var(--faint)} +.landing .packc .cap{padding:12px 16px;display:flex;justify-content:space-between;font:13px var(--font-code);color:var(--dim);border-top:1px solid var(--faint)} .landing .packc .cap b{color:var(--text)} .landing .themes{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px} -.landing .themes button{display:flex;gap:8px;align-items:center;padding:6px 12px 6px 6px;border:1px solid var(--faint);border-radius:999px;font:13px 'JetBrains Mono',monospace;color:var(--text)} -.landing .themes button[aria-pressed=true]{border-color:var(--accent)} -.landing .themes button i{display:flex;border-radius:999px;overflow:hidden} -.landing .themes button i u{width:9px;height:18px} +.landing .themes .chip i{display:flex;border-radius:var(--r-full);overflow:hidden} +.landing .themes .chip i u{width:9px;height:18px} /* Row styles for the static mini terminal; the big terminal's live in terminal.css. */ -.landing .mini{--k:var(--text);background:var(--bg);color:var(--text);font:12.5px/1.6 'JetBrains Mono',monospace;padding:14px 16px;border:1px solid var(--faint);border-radius:12px;display:flex;flex-direction:column;gap:4px;transition:background .3s} +.landing .mini{--k:var(--text);background:var(--bg);color:var(--text);font:12.5px/1.6 var(--font-code);padding:14px 16px;border:1px solid var(--faint);border-radius:12px;display:flex;flex-direction:column;gap:4px;transition:background .3s} .landing .mini .bl{display:flex;gap:8px;white-space:nowrap;overflow:hidden} .landing .mini .bl .w{color:var(--accent);font-weight:700} .landing .mini .row{display:flex;gap:7px;align-items:baseline;margin-top:2px} @@ -91,23 +86,21 @@ .landing .maker{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:stretch} .landing .maker .mini{justify-content:center} -.landing .maker .lede{align-self:center;margin:0;font-size:18px} -.landing .maker .lede a{color:var(--accent)} -.landing .maker .lede a:hover{text-decoration:underline} +.landing .maker{gap:24px 48px} +.landing .maker-cta{align-self:center} +.landing .maker-cta .lede{margin-bottom:24px} -.landing footer{padding:120px 0 110px;font:13px 'JetBrains Mono',monospace;color:var(--dim)} +.landing footer{padding:120px 0 110px;font:13px var(--font-code);color:var(--dim)} .landing footer a:hover{color:var(--text)} -.landing :focus-visible{outline:2px solid var(--accent);outline-offset:2px} - @media (max-width:800px){ .landing nav .hide-sm{display:none} } @media (max-width:900px){ .landing .maker{grid-template-columns:1fr} - .landing .seg2{flex-wrap:wrap} .landing .sec{padding-top:80px} - .landing nav.float{gap:14px;font-size:13px} + .landing nav.float{font-size:13px} + .landing nav .nav-link{padding:0 7px} .landing section.hero{padding-top:92px} .landing .install code{font-size:11.5px;white-space:normal;overflow-wrap:anywhere} .landing .moods,.landing .spins{grid-template-columns:repeat(2,1fr);gap:10px} diff --git a/docs/web/app/landing/terminal.css b/docs/web/app/landing/terminal.css index e115bbd6..d7826a42 100644 --- a/docs/web/app/landing/terminal.css +++ b/docs/web/app/landing/terminal.css @@ -1,5 +1,5 @@ /* Sizes are in em of the terminal's 13px base, so the studio can scale the whole mock by setting --z. */ -.term{--k:var(--text);background:var(--bg);color:var(--text);border-radius:.923em;overflow:hidden;font:calc(13px*var(--z,1))/1.55 'JetBrains Mono',monospace;border:1px solid color-mix(in srgb,var(--faint) 80%,#fff 8%);box-shadow:0 30px 80px -24px #000c,0 0 0 1px #0006;position:relative;transition:background .35s,color .35s;text-align:left} +.term{--k:var(--text);background:var(--bg);color:var(--text);border-radius:.923em;overflow:hidden;font:calc(13px*var(--z,1))/1.55 var(--font-code);border:1px solid color-mix(in srgb,var(--faint) 80%,#fff 8%);box-shadow:0 30px 80px -24px #000c,0 0 0 1px #0006;position:relative;transition:background .35s,color .35s;text-align:left} .term .tbar{display:flex;align-items:center;gap:.615em;padding:.769em .923em;border-bottom:1px solid var(--faint);background:color-mix(in srgb,var(--panel) 70%,var(--bg))} .term .tbar i{width:.846em;height:.846em;border-radius:50%;background:var(--faint)} .term .tbar .ttl{margin-left:.615em;color:var(--dim);font-size:.923em} diff --git a/docs/web/app/studio/Actions.tsx b/docs/web/app/studio/Actions.tsx index 122ae484..898355f4 100644 --- a/docs/web/app/studio/Actions.tsx +++ b/docs/web/app/studio/Actions.tsx @@ -1,5 +1,8 @@ import { useEffect, useRef, useState } from 'react' +import { Popover } from '@base-ui/react/popover' import type { PetFile } from '../landing/data.ts' +import { CheckIcon, DownloadIcon, LinkIcon, SendIcon } from '../ui/icons' +import { usePortal } from '../ui/portal' import { LINK_MAX, packJson, sendCommand, shareLink, type Draft, type StudioSetup } from './model.ts' type Shown = { kind: 'send' | 'link'; text: string } @@ -8,25 +11,33 @@ export function Actions({ draft, setup, pet, blocked }: { draft: Draft; setup: S const [copied, setCopied] = useState() const [shown, setShown] = useState() const box = useRef(null) - const code = useRef(null) + const linkBtn = useRef(null) + const sendBtn = useRef(null) const timer = useRef>(undefined) + const container = usePortal() + // The popup keeps its last content while it animates out. + const last = useRef(undefined) + if (shown) last.current = shown + const pop = shown ?? last.current useEffect(() => () => clearTimeout(timer.current), []) - useEffect(() => { - if (!shown) return - const el = code.current - if (el) { - const range = document.createRange() - range.selectNodeContents(el) - getSelection()?.removeAllRanges() - getSelection()?.addRange(range) - } - const key = (e: KeyboardEvent) => { if (e.key === 'Escape') setShown(undefined) } - const away = (e: PointerEvent) => { if (!box.current?.contains(e.target as Node)) setShown(undefined) } - document.addEventListener('keydown', key) - document.addEventListener('pointerdown', away) - return () => { document.removeEventListener('keydown', key); document.removeEventListener('pointerdown', away) } - }, [shown]) + const selectAll = (el: HTMLElement | null) => { + if (!el) return + const range = document.createRange() + range.selectNodeContents(el) + getSelection()?.removeAllRanges() + getSelection()?.addRange(range) + } + + // A press or focus move onto the action buttons is not "away": the send button toggles the popover itself, + // and closing here first would make that click open it again. + const onOpenChange = (open: boolean, details: Popover.Root.ChangeEventDetails) => { + if (open) return + const e = details.event as Event & { relatedTarget?: EventTarget | null } + const to = (e.type === 'focusout' ? e.relatedTarget : e.target) as Node | null + if (to && box.current?.contains(to)) return + setShown(undefined) + } // navigator.clipboard is undefined outside secure contexts; the popover then shows the text, selected, to copy by hand. const copy = (kind: Shown['kind'], text: string) => { @@ -52,16 +63,24 @@ export function Actions({ draft, setup, pet, blocked }: { draft: Draft; setup: S return (
- - - - {shown && ( -
-

{shown.kind === 'send' ? 'Paste this into Claude Code' : 'Copy this link'}

- {shown.text} - {shown.kind === 'link' && shown.text.length > LINK_MAX &&

This link is long; some chat apps cut it. Download pack.json instead if it fails.

} -
- )} + + + + + + + + {pop?.kind === 'send' ? 'Paste this into Claude Code' : 'Copy this link'} + {pop?.text} + {pop?.kind === 'link' && pop.text.length > LINK_MAX &&

This link is long; some chat apps cut it. Download pack.json instead if it fails.

} +
+
+
+
) } diff --git a/docs/web/app/studio/ColorsSection.tsx b/docs/web/app/studio/ColorsSection.tsx index 49b7c3c8..92f32cc2 100644 --- a/docs/web/app/studio/ColorsSection.tsx +++ b/docs/web/app/studio/ColorsSection.tsx @@ -96,7 +96,7 @@ export function ColorsSection({ draft, look, onDraft, onHover }: { draft: Draft; {part && (
- +
)} {follows ? ( diff --git a/docs/web/app/studio/Controls.tsx b/docs/web/app/studio/Controls.tsx index 690cc31f..717ccc58 100644 --- a/docs/web/app/studio/Controls.tsx +++ b/docs/web/app/studio/Controls.tsx @@ -8,6 +8,7 @@ import { StatusSection } from './StatusSection' import { ThemeSection } from './ThemeSection' import { editColors, type Draft, type Role, type StudioPet, type StudioSetup } from './model.ts' import { Check, Select } from './ui' +import { ChevronRightIcon } from '../ui/icons' type Props = { draft: Draft; look: Look; setup: StudioSetup; pet?: StudioPet @@ -56,7 +57,7 @@ export function Controls({ draft, look, setup, pet, onPet, onDraft, onSetup, onH

diff --git a/docs/web/app/studio/PetSection.tsx b/docs/web/app/studio/PetSection.tsx index 79ea73b0..c961390e 100644 --- a/docs/web/app/studio/PetSection.tsx +++ b/docs/web/app/studio/PetSection.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef, useState } from 'react' import { PET_ANIMS, petSheet } from '../landing/data.ts' import { Pet } from '../landing/Pet' +import { DownloadIcon } from '../ui/icons' import { SPEEDS, petFromPixels, type Pixels } from './petpng.ts' import { petAddCommand, petJson, petOut, petPartSize, petProblems, petRides, type StudioPet } from './model.ts' @@ -84,8 +85,8 @@ export function PetSection({ pet, onPet }: { pet?: StudioPet; onPet(p?: StudioPe

{problem ? '' : petRides(out) ? 'This pet rides along in Send to my Claude.' : `Too big for a link (${(petPartSize(out) / 1024).toFixed(1)} KB encoded, 4 KB max). Download it, then run:`}

{!problem && !petRides(out) && {petAddCommand(out)}}
- - + +
)} diff --git a/docs/web/app/studio/Studio.tsx b/docs/web/app/studio/Studio.tsx index 443407cb..5c0961c9 100644 --- a/docs/web/app/studio/Studio.tsx +++ b/docs/web/app/studio/Studio.tsx @@ -5,6 +5,9 @@ import { PaneField } from '../landing/PaneField' import { Terminal } from '../landing/Terminal' import { Actions } from './Actions' import { Controls } from './Controls' +import { XIcon } from '../ui/icons' +import { Segmented } from '../ui/Segmented' +import { Select } from '../ui/Select' import { petSheet } from '../landing/data.ts' import { DEFAULT_STUDIO_SETUP, draftLook, draftProblems, focusVars, fromHash, petOut, petProblems, startDraft, stateHash, type Draft, type Role, type StudioPet, type StudioSetup } from './model.ts' @@ -79,20 +82,17 @@ export function Studio() {

{problem}

- +
+ Start from + onChange(e.target.value as T)}>{options.map(o => )} - +
+ {label} + +
) } @@ -61,7 +64,7 @@ export function ColorPick({ label, value, onChange }: { label: string; value: st return (
{label} - {open && ( @@ -84,8 +87,8 @@ export function Order({ title, all, active, labels, onChange } return (
- - + +
) })} diff --git a/docs/web/app/ui/Segmented.tsx b/docs/web/app/ui/Segmented.tsx new file mode 100644 index 00000000..788b45c6 --- /dev/null +++ b/docs/web/app/ui/Segmented.tsx @@ -0,0 +1,17 @@ +import { Toggle } from '@base-ui/react/toggle' +import { ToggleGroup } from '@base-ui/react/toggle-group' + +export function Segmented({ label, labelledBy, value, options, onChange, size, ui }: { + label?: string; labelledBy?: string; value: T; options: readonly T[]; onChange(v: T): void; size?: 'lg'; ui?: boolean +}) { + const cls = ['seg', size === 'lg' && 'seg-lg', ui && 'seg-ui'].filter(Boolean).join(' ') + return ( + { if (v[0]) onChange(v[0] as T) }} + > + {options.map(o => {o})} + + ) +} diff --git a/docs/web/app/ui/Select.tsx b/docs/web/app/ui/Select.tsx new file mode 100644 index 00000000..c504bd69 --- /dev/null +++ b/docs/web/app/ui/Select.tsx @@ -0,0 +1,31 @@ +import { Select as S } from '@base-ui/react/select' +import { CheckIcon, ChevronDownIcon } from './icons' +import { usePortal } from './portal' + +export function Select({ value, options, onChange, placeholder, ...aria }: { + value: T | ''; options: readonly T[]; onChange(v: T): void; placeholder?: string; 'aria-labelledby'?: string; 'aria-label'?: string +}) { + const container = usePortal() + return ( + { if (v) onChange(v) }}> + + + + + + + + + {options.map(o => ( + + + {o} + + ))} + + + + + + ) +} diff --git a/docs/web/app/ui/icons.tsx b/docs/web/app/ui/icons.tsx new file mode 100644 index 00000000..67cf5c10 --- /dev/null +++ b/docs/web/app/ui/icons.tsx @@ -0,0 +1,15 @@ +type P = React.SVGProps + +const base = { viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true } as const + +export const CopyIcon = (p: P) => +export const CheckIcon = (p: P) => +export const ArrowRightIcon = (p: P) => +export const ChevronDownIcon = (p: P) => +export const ChevronRightIcon = (p: P) => +export const ChevronUpIcon = (p: P) => +export const XIcon = (p: P) => +export const DownloadIcon = (p: P) => +export const LinkIcon = (p: P) => +export const SendIcon = (p: P) => +export const StarIcon = (p: P) => diff --git a/docs/web/app/ui/portal.tsx b/docs/web/app/ui/portal.tsx new file mode 100644 index 00000000..9a5fe87e --- /dev/null +++ b/docs/web/app/ui/portal.tsx @@ -0,0 +1,7 @@ +import { createContext, useContext } from 'react' + +// The element popups portal into. It is the .landing root, because the pack's colors are custom properties set +// there and a popup portalled to would render without them. +export const PortalContext = createContext(null) + +export const usePortal = () => useContext(PortalContext) diff --git a/docs/web/app/ui/system.css b/docs/web/app/ui/system.css new file mode 100644 index 00000000..58cbc700 --- /dev/null +++ b/docs/web/app/ui/system.css @@ -0,0 +1,109 @@ +/* Tokens and shared controls for the landing page and the studio. Colors come from the pack (lookVars sets + --bg, --panel, --text, --dim, --faint, --accent on .landing), so every token here is derived, never fixed, + and a pack change restyles every control. Popups portal into .landing for the same reason. */ +.landing{ + --font-ui:Geist,system-ui,sans-serif; + --font-code:'JetBrains Mono',ui-monospace,monospace; + --font-logo:'Pixelify Sans',monospace; + + --r-sm:6px;--r-md:8px;--r-lg:12px;--r-full:999px; + --h-sm:30px;--h-md:36px;--h-lg:44px; + + --surface:var(--panel); + --line:var(--faint); + --line-strong:color-mix(in srgb,var(--dim) 55%,var(--faint)); + --on-accent:var(--bg); + --tint:color-mix(in srgb,var(--text) 7%,transparent); + --tint-strong:color-mix(in srgb,var(--text) 12%,transparent); + --accent-soft:color-mix(in srgb,var(--accent) 14%,transparent); + --shadow-pop:0 12px 32px -8px #000b,0 2px 6px -2px #0006; + + --ease:cubic-bezier(.16,1,.3,1); + --t-fast:120ms;--t:220ms; + + caret-color:var(--accent); + accent-color:var(--accent); + scrollbar-color:var(--line-strong) transparent; +} +.landing ::selection{background:color-mix(in srgb,var(--accent) 38%,transparent);color:var(--text)} +.landing :focus-visible{outline:2px solid var(--accent);outline-offset:2px} + +/* Button: one shape, three weights. Primary is the single action a view wants; secondary sits beside it; + ghost is for icon and toolbar actions that should not compete with content. */ +.landing .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;flex:none;height:var(--h-md);padding:0 14px;border:1px solid transparent;border-radius:var(--r-md);font:600 13.5px/1 var(--font-ui);letter-spacing:-.005em;white-space:nowrap;color:var(--text);cursor:pointer;user-select:none;transition:background-color var(--t-fast),border-color var(--t-fast),color var(--t-fast),transform var(--t-fast) var(--ease)} +.landing .btn svg{flex:none;width:16px;height:16px} +.landing .btn:active:not(:disabled){transform:translateY(1px)} +.landing .btn:disabled{opacity:.45;cursor:not-allowed} +.landing .btn-primary{background:var(--accent);color:var(--on-accent)} +.landing .btn-primary:hover:not(:disabled){background:color-mix(in srgb,var(--accent) 86%,var(--text))} +.landing .btn-secondary{background:var(--surface);border-color:var(--line)} +.landing .btn-secondary:hover:not(:disabled){border-color:var(--line-strong);background:color-mix(in srgb,var(--surface) 100%,var(--text) 5%)} +.landing .btn-secondary[aria-expanded=true]{border-color:var(--accent)} +.landing .btn-ghost{color:var(--dim)} +.landing .btn-ghost:hover:not(:disabled){color:var(--text);background:var(--tint)} +.landing .btn-link{height:auto;padding:0;border:0;color:var(--accent);font-weight:500;text-decoration:underline;text-decoration-color:color-mix(in srgb,var(--accent) 45%,transparent);text-underline-offset:3px} +.landing .btn-link:hover{text-decoration-color:var(--accent)} +.landing .btn-sm{height:var(--h-sm);padding:0 10px;font-size:12.5px;border-radius:var(--r-sm)} +.landing .btn-sm svg{width:14px;height:14px} +.landing .btn-icon{width:var(--h-md);padding:0} +.landing .btn-sm.btn-icon{width:var(--h-sm)} + +/* Segmented control: one choice from a short fixed set. Values are command arguments, so they stay in mono. */ +.landing .seg{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;max-width:100%;border:1px solid var(--line);border-radius:calc(var(--r-sm) + 3px);background:color-mix(in srgb,var(--surface) 80%,transparent)} +.landing .seg-item{height:var(--h-sm);padding:0 12px;border-radius:var(--r-sm);font:500 13px/1 var(--font-code);color:var(--dim);cursor:pointer;transition:background-color var(--t-fast),color var(--t-fast)} +.landing .seg-item:hover:not([aria-pressed=true]){color:var(--text);background:var(--tint)} +.landing .seg-item[aria-pressed=true]{background:var(--accent);color:var(--on-accent);font-weight:600} +.landing .seg-lg{padding:4px;border-radius:calc(var(--r-md) + 4px)} +.landing .seg-lg .seg-item{height:var(--h-md);padding:0 16px;border-radius:var(--r-md)} +.landing .seg-ui .seg-item{font-family:var(--font-ui)} + +/* Chip: a pressable option that also shows something (a palette, a swatch). */ +.landing .chip{display:inline-flex;align-items:center;gap:8px;height:var(--h-sm);padding:0 12px 0 6px;border:1px solid var(--line);border-radius:var(--r-full);font:13px/1 var(--font-code);color:var(--text);cursor:pointer;transition:border-color var(--t-fast),background-color var(--t-fast)} +.landing .chip:hover{border-color:var(--line-strong)} +.landing .chip[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft)} + +/* Fields */ +.landing .field,.landing select,.landing input[type=text],.landing input[type=number]{appearance:none;-webkit-appearance:none;display:flex;align-items:center;gap:8px;width:100%;min-width:0;max-width:100%;height:var(--h-md);padding:0 10px;font:13px var(--font-code);color:var(--text);background-color:var(--surface);border:1px solid var(--line);border-radius:var(--r-md);text-align:left;cursor:text;transition:border-color var(--t-fast)} +.landing .field{cursor:pointer} +.landing .field:hover,.landing input[type=text]:hover,.landing input[type=number]:hover{border-color:var(--line-strong)} +.landing .field[data-popup-open]{border-color:var(--accent)} +.landing input[aria-invalid=true]{border-color:var(--fail)} +.landing .field-value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.landing .field-value[data-placeholder]{color:var(--dim)} +.landing .field>svg{flex:none;width:14px;height:14px;color:var(--dim)} +.landing input[type=number]{-moz-appearance:textfield} +.landing input[type=number]::-webkit-inner-spin-button,.landing input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0} +.landing input[type=file]{font:13px var(--font-ui);color:var(--dim);max-width:100%} +.landing input[type=file]::file-selector-button{height:var(--h-sm);margin-right:10px;padding:0 10px;border:1px solid var(--line);border-radius:var(--r-sm);background:var(--surface);font:600 12.5px var(--font-ui);color:var(--text);cursor:pointer} +.landing input[type=file]::file-selector-button:hover{border-color:var(--line-strong)} + +.landing input[type=checkbox],.landing input[type=radio]{appearance:none;-webkit-appearance:none;flex:none;position:relative;width:18px;height:18px;margin:0;background:var(--surface);border:1px solid var(--line-strong);cursor:pointer;transition:background-color var(--t-fast),border-color var(--t-fast)} +.landing input[type=checkbox]{border-radius:5px} +.landing input[type=radio]{border-radius:50%} +.landing input[type=checkbox]:hover,.landing input[type=radio]:hover{border-color:var(--dim)} +.landing input[type=checkbox]:checked{background:var(--accent);border-color:var(--accent)} +.landing input[type=checkbox]:checked::after{content:'';position:absolute;left:5px;top:1px;width:5px;height:10px;border:solid var(--on-accent);border-width:0 2px 2px 0;transform:rotate(45deg)} +.landing input[type=radio]:checked{background:var(--accent);border-color:var(--accent);box-shadow:inset 0 0 0 3px var(--surface)} + +.landing input[type=range]{appearance:none;-webkit-appearance:none;width:100%;height:24px;margin:0;background:transparent;cursor:pointer} +.landing input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:2px;background:color-mix(in srgb,var(--dim) 45%,var(--surface))} +.landing input[type=range]::-moz-range-track{height:4px;border-radius:2px;background:color-mix(in srgb,var(--dim) 45%,var(--surface))} +.landing input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;margin-top:-6px;border-radius:50%;background:var(--accent);border:2px solid var(--surface)} +.landing input[type=range]::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:var(--accent);border:2px solid var(--surface)} + +/* Popups (Base UI Select and Popover) */ +.landing .pop-positioner{z-index:50;outline:0} +.landing .pop{min-width:var(--anchor-width);background:var(--surface);color:var(--text);border:1px solid var(--line);border-radius:var(--r-md);box-shadow:var(--shadow-pop);transform-origin:var(--transform-origin);transition:opacity var(--t-fast),transform var(--t) var(--ease);outline:0} +.landing .pop[data-starting-style],.landing .pop[data-ending-style]{opacity:0;transform:scale(.97)} +.landing .pop[data-side=none]{transition:none} +.landing .menu{padding:4px;max-height:var(--available-height);overflow-y:auto} +.landing .menu-item{display:grid;grid-template-columns:14px 1fr;align-items:center;gap:8px;height:32px;padding:0 10px 0 8px;border-radius:var(--r-sm);font:13px var(--font-code);cursor:default;outline:0;user-select:none} +.landing .menu-item[data-highlighted]{background:var(--tint-strong)} +.landing .menu-item svg{width:14px;height:14px;color:var(--accent)} + +@media (prefers-reduced-motion:reduce){ + .landing .btn,.landing .pop{transition:none} +} +@media (pointer:coarse){ + .landing{--h-sm:40px;--h-md:44px} +} diff --git a/docs/web/package.json b/docs/web/package.json index 5c1a4a3f..c1d8cc1f 100644 --- a/docs/web/package.json +++ b/docs/web/package.json @@ -14,14 +14,15 @@ "check": "fumadocs-mdx && react-router typegen && tsc && node scripts/check-links.ts" }, "dependencies": { + "@base-ui/react": "^1.8.0", "@types/mdx": "^2.0.14", "fumadocs-core": "^16.16.1", "fumadocs-mdx": "^15.4.6", "fumadocs-ui": "^16.16.1", + "isbot": "^5", "react": "^19.3.0", "react-dom": "^19.3.0", - "react-router": "^8.4.0", - "isbot": "^5" + "react-router": "^8.4.0" }, "devDependencies": { "@react-router/dev": "^8.4.0", diff --git a/docs/web/pnpm-lock.yaml b/docs/web/pnpm-lock.yaml index 9cf1a525..291f5665 100644 --- a/docs/web/pnpm-lock.yaml +++ b/docs/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@base-ui/react': + specifier: ^1.8.0 + version: 1.8.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@types/mdx': specifier: ^2.0.14 version: 2.0.14 @@ -127,6 +130,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -139,6 +146,33 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@base-ui/react@1.8.0': + resolution: {integrity: sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.4.0': + resolution: {integrity: sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -2156,6 +2190,9 @@ packages: remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + reselect@5.3.0: + resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} + rolldown@1.2.12: resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2282,6 +2319,11 @@ packages: '@types/react': optional: true + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + valibot@1.5.0: resolution: {integrity: sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==} peerDependencies: @@ -2448,6 +2490,8 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/runtime@7.29.10': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -2471,6 +2515,29 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@base-ui/react@1.8.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.10 + '@base-ui/utils': 0.4.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@floating-ui/react-dom': 2.1.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@floating-ui/utils': 0.2.12 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + use-sync-external-store: 1.7.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + + '@base-ui/utils@0.4.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.10 + '@floating-ui/utils': 0.2.12 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + reselect: 5.3.0 + use-sync-external-store: 1.7.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -4568,6 +4635,8 @@ snapshots: transitivePeerDependencies: - supports-color + reselect@5.3.0: {} + rolldown@1.2.12: dependencies: '@oxc-project/types': 0.152.0 @@ -4734,6 +4803,10 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 + use-sync-external-store@1.7.0(react@19.3.0): + dependencies: + react: 19.3.0 + valibot@1.5.0(typescript@7.0.2): optionalDependencies: typescript: 7.0.2 From 7dbf200160865787d1039e6f8bdcbf2301175acf Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Thu, 8 Oct 2026 16:19:53 +0300 Subject: [PATCH 2/2] fix(site): popover closes on Escape and Download; touch-size select options Only presses or focus moves onto the share and send buttons keep the send popover open, so Escape and Download pack.json close it. Select options grow to 44px under pointer: coarse like the other controls. Signed-off-by: NovusEdge --- docs/web/app/studio/Actions.tsx | 12 +++++++----- docs/web/app/ui/system.css | 1 + 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/web/app/studio/Actions.tsx b/docs/web/app/studio/Actions.tsx index 898355f4..81abc636 100644 --- a/docs/web/app/studio/Actions.tsx +++ b/docs/web/app/studio/Actions.tsx @@ -29,13 +29,15 @@ export function Actions({ draft, setup, pet, blocked }: { draft: Draft; setup: S getSelection()?.addRange(range) } - // A press or focus move onto the action buttons is not "away": the send button toggles the popover itself, - // and closing here first would make that click open it again. + // A press or focus move onto the share or send button is not "away": both set the popover themselves, and + // closing here first would make the send button's toggling click open it again. const onOpenChange = (open: boolean, details: Popover.Root.ChangeEventDetails) => { if (open) return - const e = details.event as Event & { relatedTarget?: EventTarget | null } - const to = (e.type === 'focusout' ? e.relatedTarget : e.target) as Node | null - if (to && box.current?.contains(to)) return + if (details.reason === 'outside-press' || details.reason === 'focus-out') { + const e = details.event as Event & { relatedTarget?: EventTarget | null } + const to = (e.type === 'focusout' ? e.relatedTarget : e.target) as Node | null + if (to && (linkBtn.current?.contains(to) || sendBtn.current?.contains(to))) return + } setShown(undefined) } diff --git a/docs/web/app/ui/system.css b/docs/web/app/ui/system.css index 58cbc700..8d7a297c 100644 --- a/docs/web/app/ui/system.css +++ b/docs/web/app/ui/system.css @@ -106,4 +106,5 @@ } @media (pointer:coarse){ .landing{--h-sm:40px;--h-md:44px} + .landing .menu-item{height:var(--h-lg)} }