diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0102ecb..3ed33fb 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -11,7 +11,7 @@ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, plugins: ['jsx-a11y'], - settings: { react: { version: '18.3' } }, + settings: { react: { version: 'detect' } }, rules: { 'react/react-in-jsx-scope': 'off', 'react/prop-types': 'off', diff --git a/components.json b/components.json new file mode 100644 index 0000000..8513427 --- /dev/null +++ b/components.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/global.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": { + "@coss": "https://coss.com/ui/r/{name}.json" + } +} diff --git a/docs/redesign/README.md b/docs/redesign/README.md new file mode 100644 index 0000000..6111605 --- /dev/null +++ b/docs/redesign/README.md @@ -0,0 +1,566 @@ +# QueryCraft × coss ui — redesign handoff + +Implementation brief for rebuilding the QueryCraft popup UI on [coss ui](https://coss.com/ui) (Base UI + Tailwind v4). +Written for Claude Code. Read this whole file before touching code, then work **one phase at a time** (§10). + +- **Design canvas (source of truth for visuals):** https://claude.ai/code/artifact/6f586460-f2ba-49dd-a935-a47a810fbb3b — private; ask the owner for screenshots if you need to see it. +- **Mockup sources:** `docs/redesign/mockups/*.dc.html` — the canvas artboards as authored. They are **not runnable** (they need the canvas runtime) but every size, colour, copy string and state is written inline. Read them when this doc is ambiguous. `Main` = param list, `Overflow` = max-height states, `Settings`, `Saved`. +- **Branch:** work on `redesign/coss-ui` off `main`. The `DSL-beta` branch is out of scope. + +--- + +## 1. Scope + +| Area | Status | What to build | +|---|---|---| +| Param list (main view) | Designed | Full rebuild (§6.1) | +| Max-height behaviour, internal scroll, key Autocomplete | Designed | Full rebuild (§5, §6.2) | +| Settings sheet: accent + **theme preference (new)** | Designed | Full rebuild (§6.3) | +| Saved URLs sheet: list, row menu, save/edit form | Designed | Full rebuild (§6.4) | +| Command palette (⌘K) | Spec only, no mockup | Phase 6, §6.5 | +| Loading / unsupported / error states | Not designed | Interim: coss `Empty` with existing copy (§6.6) | +| JSON drill-down (`JsonStack/*`) | Not designed | Restyle with coss primitives only, keep behaviour (§6.6) | +| DSL filter builder, token/chip primitive | Not designed | **Out of scope** | + +**Do not** add features beyond this doc. Where the doc proposes something new it is marked **(new)**; build only those. + +## 2. Hard constraints + +1. **Manifest V3 popup.** Width **380px**. Height = content height, **max 600px** (Chrome's cap). Height is set from JS by a `ResizeObserver` on the app container (§5.1). +2. **No remote resources.** No font CDNs, no inline ``, before the module script. + - Wrap it in `try/catch`. + +## 5. Layout, sizing, scrolling + +### 5.1 Popup height + +- `html` and `body`: width 380px, height auto. +- The app container is `flex flex-col max-h-[600px]` with natural height. +- In `main.tsx`, observe the app container. On each entry set `document.documentElement.style.height = document.body.style.height = Math.min(600, Math.ceil(entry.borderBoxSize[0].blockSize)) + 'px'`. +- **While a Sheet is open**, set the app container's `min-height` to the sheet content's natural height (capped at 600). Otherwise a sheet opened over a short view, e.g. an empty list, gets clipped. Measure the sheet popup with its own `ResizeObserver`. + +### 5.2 Scroll model + +``` +header (flex-none) +main (flex-1 min-h-0, flex-col gap-3, px-4 py-3) + URL editor (flex-none, max 4 lines, own internal scroll) + filter (flex-none) + section "Parameters" (flex-1 min-h-0, flex-col gap-2) + section header (flex-none) + ScrollArea scrollFade (flex-1 min-h-0) ← the ONLY scrolling list + Separator + add row (flex-none, pinned) +footer action bar (flex-none) +``` + +- At 600px max the list viewport is ~241px (~7 rows). +- **Focus never hidden.** On the ScrollArea viewport set `scroll-padding-block: 1.5rem`, the same as coss's `--fade-size`. A row focused by Tab then always lands fully above or below the scroll fade. +- **Full-bleed viewport.** Give the ScrollArea `-mx-4` and its content `px-4`. Then `overflow` does not clip the 3px focus rings of the edge inputs. +- The URL editor shows a bottom fade (`mask-b-from-[calc(100%-1.5rem)]`) **only** when it overflows. Set a `data-overflowing` attribute from `scrollHeight > clientHeight`. + +### 5.3 Portaled popups + +Default positioner props everywhere: `collisionPadding={8}`, `sideOffset={4}`. Cap heights with `--available-height`. + +- **Key Autocomplete (add row):** + - Anchor to the whole add-row Group (`anchor={groupRef}`), so the popup is the row's width (320px), not the 128px key cell's. + - `side="bottom"` with flip. In practice it opens **up**, because the add row sits near the bottom. + - `[--popup-max-h:12rem]`; the list scrolls inside with the coss scroll fade. +- **Saved URLs row Menu:** `align="end"`, below its ⋯ trigger. +- Toasts render bottom-centre, 8px above the action bar, max width 348px. + +## 6. Screens + +All copy below is final. Sizes are px with the Tailwind class in brackets; at our density, `sm:` classes are the active ones. + +### 6.1 Param list (`Main` artboard) + +Content height ~557px with 6 params. + +**Header** `[flex items-center justify-between border-b py-3 ps-4 pe-3]` +- **Left:** + - Logo tile 24px `[size-6 rounded-sm bg-primary text-primary-foreground]` with lucide `Search` 14px, `aria-hidden`. + - `

` "QueryCraft" `[text-sm font-semibold]`. +- **Right** `[flex gap-0.5]`: + - Command palette trigger: `Button variant="ghost"` `[h-7 px-1 gap-1]` containing `KbdGroup` `⌘` `K`, `aria-label="Command palette"`, `aria-keyshortcuts="Meta+K"`. + - Saved URLs: `Button variant="ghost" size="icon-sm"` with `Bookmark`, `aria-keyshortcuts="Meta+S"`. + - Settings: `Button variant="ghost" size="icon-sm"` with `Settings`. + - Icon-only buttons get a `Tooltip`: label + Kbd. + +**URL editor** — InputGroup shell + `InputGroupTextarea`. +- Text: `font-mono sm:text-code leading-5`, 5px vertical padding, 11px horizontal. +- Max 4 lines (80px), then internal scroll. +- Keep the existing highlight-overlay technique: an `aria-hidden` mirror div under a transparent-text textarea. Sync scroll. +- Colours: + - Base URL and the `? & =` separators: `text-muted-foreground`. + - Keys: `text-key-foreground font-medium`. + - Values: `text-foreground`. +- `aria-label="Current URL"`. Enter commits, as today. + +**Filter** — `InputGroup` (32px): +- `InputGroupAddon` with `Search` icon (muted). +- `InputGroupInput type="search" placeholder="Filter parameters"`, `aria-label="Filter parameters by key"`, `aria-keyshortcuts="/"`. +- `InputGroupAddon align="inline-end"` containing `/`. + +**Section header** `[flex h-6 items-center justify-between]` +- **Left** `[flex items-center gap-1.5]`: + - `

` "Parameters" `[text-xs font-medium text-muted-foreground]`. + - `Badge variant="secondary"` with the visible count, `aria-label="{n} parameters"`. +- **Right:** `Button variant="ghost" size="icon-xs"` with `Ellipsis`, `aria-label="Parameter actions"`. It opens a `Menu` **(new)** with: + - Copy as query string + - Copy as JSON + - Sort keys A→Z + - separator + - Remove all parameters (destructive variant; undoable, see §7) + +**Row** — `
  • ` containing: + +```tsx + + + + {valueControl} + + +``` + +| Value type | Control | +|---|---| +| string / number | `Input size="sm"` `[flex-1 min-w-0 font-mono sm:text-code]`, `aria-label="Value"`. Inputs truncate with `text-ellipsis` at rest. | +| boolean | InputGroup shell (28px): `Switch` (`aria-label="Value"`) + `InputGroupText` with the literal value in mono, casing preserved. State is never carried by the switch alone. | +| structured JSON | `Button variant="outline" size="sm"` `[flex-1 min-w-0 justify-between font-mono font-normal sm:text-code text-muted-foreground]`: truncated preview + `ChevronRight`. `aria-label="Edit {key}, JSON array, {n} items"`. Opens the existing JSON stack. | +| DSL (later, DSL-beta) | Same button, sans text: `ListFilter` icon + "{n} conditions" + `ChevronRight`. Accessible name includes the full humanised expression. Document only. | + +Wrap each row in a `ContextMenu` **(new)**: Copy key, Copy value, Copy `key=value`, Duplicate, separator, Remove. It opens on right-click and on Shift+F10 / the Menu key. This replaces the old hover-only copy buttons, which go away. + +**Add row** — `role="group" aria-label="Add parameter"`: + +```tsx + + } placeholder="key" aria-label="New parameter key" /> {/* basis-2/5 */} + + + + +``` + +- Suggestions: + - "From saved URLs on {host}": keys from saved URLs on the same host, shown first, only when non-empty. + - "Common keys": `utm_source utm_medium utm_campaign utm_term utm_content utm_id utm_source_platform page limit sort q debug`. +- Item rendering: the typed prefix in `text-muted-foreground`, the completion in `font-medium`. + +**Action bar** `[flex items-center gap-2 border-t px-4 py-3]`: +- `Button` "Apply" containing `⌘↵`. Kbd on primary: `bg-primary-foreground/12 text-primary-foreground`; this is a small gap in coss. +- `Button variant="ghost"` "Reset". +- `flex-1` spacer. +- `Button variant="outline"` "Copy". The label swaps to "Copied" for 1.6s. +- `Button variant="outline"` "Save". Opens the save form directly. + +All footer buttons are the default height (32px), text-only. + +### 6.2 Max-height states (`Overflow` artboard) + +16 params, popup at 600px. + +- **(a) Tab focus at the list edge.** The focused row (`env`) sits fully above the bottom fade because of `scroll-padding-block`. The ScrollArea thumb is visible while scrolling. +- **(b) Add-row key focused, `utm_` typed.** + - The Autocomplete flips above the row, 320px wide, capped at 192px, with a fade at the bottom. + - The highlighted item has `bg-accent` + inset `--ring`. + - `AutocompleteStatus` announces "{n} suggestions". + +### 6.3 Settings sheet (`Settings` artboard) + +`Sheet side="right" density="compact"`: coss default width `calc(100% - 3rem)` = 332px. The left 48px shows the editor under `bg-black/32 backdrop-blur-sm`. Clicking it or pressing Esc closes the sheet. + +- **Header** `[flex items-center gap-2 py-3 ps-4 pe-2]`: + - `SheetTitle` "Settings" `[text-base font-semibold]`. + - `SheetClose` as `Button variant="ghost" size="icon"` with `X`, `aria-label="Close settings"`. Use `showCloseButton={false}` and render the close button in the header row. +- **Panel** `[flex flex-col gap-4]`: + 1. **Accent color.** + - Label row: "Accent color" `[text-sm font-medium]`, and the selected swatch's *name* right-aligned in muted. Colour is never the only cue. + - `RadioGroup aria-label="Accent color"`, `grid grid-cols-6 gap-2`. + - 11 options: **Mono** (fill `--foreground`) then Blue, Cyan, Teal, Green, Lime, Amber, Orange, Red, Pink, Violet. Hex values live in `src/lib/accents.ts`. + - Each swatch: 32px high, `rounded-md`, inset 1px `black/10` (several swatches are < 3:1 on white). + - Selected swatch: 2px offset outline in `--foreground` + `Check` icon in `readableOn(hex)`. + - **Mono replaces the old "Reset to monochrome" link.** + - `FieldDescription`: "Tints the primary button, switches and URL keys. Text on the accent is picked to keep 4.5:1." + - The swatch is a gap in coss: compose `RadioGroup` + `Radio` with a custom render. Do not create a new package-level component yet. + 2. **Preview.** + - Label "Preview" `[text-xs font-medium text-muted-foreground]`. + - `Frame` > `FramePanel p-3`, `inert` + `aria-hidden`. + - Contents: `?view=table&live=true` with keys in `text-key-foreground`, a checked Switch + mono "true", and a small primary "Apply ⌘↵". It shows exactly what the accent changes. + 3. `Separator`. + 4. **Theme (new).** + - Label "Theme". + - Segmented control with radio semantics, `aria-label="Theme"`, full width, three equal items, each a 16px icon plus label: `Monitor` System · `Sun` Light · `Moon` Dark. + - Description text: + - System: "Follows your OS appearance — {light|dark} right now." + - Light or Dark: "Overrides your OS appearance for QueryCraft only." +- Changes apply instantly; there is no footer. Announce "Accent: Violet" / "Theme: Dark" through the hidden LiveRegion. +- On open, focus goes to the checked accent radio. + +### 6.4 Saved URLs sheet (`Saved` artboard) + +Same sheet chrome (compact, 332px). + +**List mode** +- **Header:** "Saved URLs" + `Badge variant="secondary"` total count + close button (`aria-label="Close saved URLs"`). +- **Panel:** + - **Filter (new):** `InputGroup` with search icon, placeholder "Filter saved URLs", trailing `/`. It matches label, URL and group name. + - **Groups:** one `Collapsible` per group. The trigger is a full-width ghost row (28px) `[text-xs font-medium text-muted-foreground]`: `ChevronDown` (rotates −90° when closed) + group name + `Badge variant="secondary"` count, with `aria-expanded`. Groups start expanded; empty groups are hidden. + - **Rows:** inside ` + + + ); + })} ); } diff --git a/src/components/SavedLinksDrawer/SaveForm.tsx b/src/components/SavedLinksDrawer/SaveForm.tsx index cae4504..90b4b99 100644 --- a/src/components/SavedLinksDrawer/SaveForm.tsx +++ b/src/components/SavedLinksDrawer/SaveForm.tsx @@ -1,8 +1,11 @@ -import { useId } from 'react'; +import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import type { Group } from '@/types'; -import { Button } from '../Button'; -import { GroupSelector } from '../GroupSelector'; -import styles from './SavedLinksDrawer.module.css'; +import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { GroupCombobox } from './GroupCombobox'; + +/** Read by the sheet: Name takes focus when the form opens (§7.2). */ +export const SAVE_NAME_ID = 'saved-url-name'; interface SaveFormProps { label: string; @@ -11,13 +14,13 @@ interface SaveFormProps { onGroupChange: (id: string) => void; groups: Group[]; onCreateNewGroup: (name: string) => void; - onSubmit: () => void; - onCancel: () => void; previewUrl: string; - submitLabel?: string; } -/** Shared form body for both "save current URL" and "edit saved URL" modes. */ +/** + * Body of the save and edit forms (docs/redesign §6.4). The sheet around it + * owns the header, the Cancel / Save footer and ⌘↵. + */ export function SaveForm({ label, onLabelChange, @@ -25,61 +28,155 @@ export function SaveForm({ onGroupChange, groups, onCreateNewGroup, - onSubmit, - onCancel, previewUrl, - submitLabel = 'Save', }: SaveFormProps) { - const labelId = useId(); + const nameId = SAVE_NAME_ID; + const groupFieldId = useId(); + const groupDescriptionId = useId(); const urlId = useId(); + const urlDescriptionId = useId(); + + // Focus lands on Name whenever the form is shown (§7.2) — also when it + // replaces the list inside an open sheet. Deferred a tick so a closing row + // menu has already restored focus to its trigger. + useEffect(() => { + const timer = window.setTimeout(() => document.getElementById(nameId)?.focus(), 0); + return () => window.clearTimeout(timer); + }, [nameId]); return ( -
    -
    - - + + + Name (optional) + + onLabelChange(e.target.value)} placeholder="e.g. Staging with debug flag" maxLength={80} /> -
    + Shown instead of the URL in the list. + + + + Group + + + Type to find a group, or create a new one. + + - +
    + ); +} -
    - - {/* Read-only: the URL is edited in the main view, shown here to confirm. */} -