diff --git a/README.md b/README.md index 5effcb7..01283d7 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,25 @@ -# OpenCode Theme Editor +# OpenCode Theme Studio

Design a theme that actually feels like OpenCode before you export it.

- OpenCode Theme Editor is a local-first theme studio for crafting dark and light OpenCode themes with live preview, curated presets, raw JSON control, shareable links, and one-command install. + OpenCode Theme Studio is a local-first theme studio for crafting dark and light OpenCode themes with live preview, curated presets, raw JSON control, shareable links, and one-command install.

- Launch the app + Launch the app · - GitHub + GitHub · OpenCode theme docs

+

+ +

+ ## Why people use it - See changes in an OpenCode-like preview while you edit, instead of guessing from JSON alone. @@ -21,7 +27,7 @@ - Start fast with built-in OpenCode themes, curated palette presets, and remix controls. - Move from semantic editing to token-level control to raw JSON without leaving the same workflow. - Keep drafts in your browser with IndexedDB autosave; no account or backend required. -- Share an editable link or copy a ready-to-run install command for OpenCode. +- Share an editable link, import a local OpenCode theme, or copy a ready-to-run install command. ## What you can do @@ -40,13 +46,14 @@ 3. Refine colors in `Basic`, `Full`, or `JSON`. 4. Flip between dark and light to tune both modes. 5. Open `Save` to download files, copy a share link, or generate the OpenCode install command. +6. Open `{...}` to import an existing local OpenCode theme or edit the full bundle JSON directly. ## Install in OpenCode From the `Save` tab, copy the generated command and run it from your project root. ```bash -curl -fsSL https://kkugot.github.io/opencode-theme-editor/install.sh | bash -s -- +curl -fsSL https://kkugot.github.io/opencode-theme-studio/import-export.sh | bash -s -- install ``` The installer: @@ -56,6 +63,28 @@ The installer: You can also type `!` inside OpenCode, paste the generated command, and restart OpenCode once. +## Import from OpenCode + +From the `{...}` tab, copy the import command and run it inside OpenCode. + +```bash +curl -fsSL https://kkugot.github.io/opencode-theme-studio/import-export.sh | bash -s -- import +``` + +The import flow: + +- reads your current local OpenCode theme from `.opencode/themes/` or `~/.config/opencode/themes/` +- converts it into a Theme Studio share URL +- opens the browser with that theme already loaded in the editor + +This is useful when you want to: + +- build a matching light or dark companion for an existing theme +- refine a local custom theme without manually copying JSON +- share your local theme setup with someone else through a browser link + +If you do not want to run the import-export script, you can also paste your current theme JSON directly into the `{...}` tab. Theme Studio accepts the full dark/light bundle there and keeps the JSON updated as you edit. + ## Manual export If you prefer file-based setup: @@ -79,6 +108,7 @@ Optional project default in `.opencode/tui.json`: - The share link reopens the exact theme in the editor, including both dark and light modes. - Shared URLs hydrate the editor before local draft restore, so collaborators land on the intended version immediately. +- Install and share commands use a compressed payload that contains the full dark + light theme bundle, not just the current mode. ## Local development @@ -97,4 +127,4 @@ Useful commands: ## Notes - Drafts are stored locally in your browser via IndexedDB. -- Share-link and install-command generation require a browser with `CompressionStream` support. +- Share-link, install-command, and import/export payload generation use compressed theme data and require a browser with `CompressionStream` support. diff --git a/index.html b/index.html index 6743f94..4b0b9cc 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - OpenCode Theme Editor + OpenCode Theme Studio
diff --git a/package.json b/package.json index fb3d928..e39d9fe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opencode-theme-editor", "private": true, - "version": "1.0.0", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/public/import-export.sh b/public/import-export.sh new file mode 100644 index 0000000..fd9c745 --- /dev/null +++ b/public/import-export.sh @@ -0,0 +1,517 @@ +#!/usr/bin/env sh +set -eu + +if [ "$#" -lt 1 ]; then + printf '%s\n' "usage: curl -fsSL | bash -s -- install " + printf '%s\n' " or: curl -fsSL | bash -s -- import [theme-name-or-path]" + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + printf '%s\n' "python3 is required to import or install OpenCode themes" + exit 1 +fi + +MODE="$1" +shift + +python3 - "$MODE" "$@" <<'PY' +import base64 +import json +import pathlib +import re +import shutil +import subprocess +import sys +import urllib.parse +import zlib + +SCHEMA_URL = 'https://opencode.ai/theme.json' +TUI_SCHEMA_URL = 'https://opencode.ai/tui.json' +CODEC_PREFIX_OT1 = 'ot1' +LENGTH_WIDTH = 5 +CHUNK_WIDTH = 5 +TOKEN_NAMES = [ + 'primary', + 'secondary', + 'accent', + 'error', + 'warning', + 'success', + 'info', + 'text', + 'textMuted', + 'selectedListItemText', + 'background', + 'backgroundPanel', + 'backgroundElement', + 'backgroundMenu', + 'border', + 'borderActive', + 'borderSubtle', + 'diffAdded', + 'diffRemoved', + 'diffContext', + 'diffHunkHeader', + 'diffHighlightAdded', + 'diffHighlightRemoved', + 'diffAddedBg', + 'diffRemovedBg', + 'diffContextBg', + 'diffLineNumber', + 'diffAddedLineNumberBg', + 'diffRemovedLineNumberBg', + 'markdownText', + 'markdownHeading', + 'markdownLink', + 'markdownLinkText', + 'markdownCode', + 'markdownBlockQuote', + 'markdownEmph', + 'markdownStrong', + 'markdownHorizontalRule', + 'markdownListItem', + 'markdownListEnumeration', + 'markdownImage', + 'markdownImageText', + 'markdownCodeBlock', + 'syntaxComment', + 'syntaxKeyword', + 'syntaxFunction', + 'syntaxVariable', + 'syntaxString', + 'syntaxNumber', + 'syntaxType', + 'syntaxOperator', + 'syntaxPunctuation', +] + + +def slugify(value: str) -> str: + cleaned = re.sub(r'[^a-z0-9]+', '-', value.strip().lower()) + return cleaned.strip('-') or 'opencode-theme' + + +def rgba_to_color(red: int, green: int, blue: int, alpha: int) -> str: + if alpha <= 0: + return 'transparent' + + if alpha >= 255: + return f'#{red:02x}{green:02x}{blue:02x}' + + return f'#{red:02x}{green:02x}{blue:02x}{alpha:02x}' + + +def decode_ot1_payload(encoded: str) -> dict: + body = encoded[len(CODEC_PREFIX_OT1):] + byte_length = int(body[:LENGTH_WIDTH], 36) + chunk_data = body[LENGTH_WIDTH:] + + if len(chunk_data) % CHUNK_WIDTH != 0: + raise SystemExit('invalid ot1 theme payload body') + + decoded = bytearray() + + for offset in range(0, len(chunk_data), CHUNK_WIDTH): + value = int(chunk_data[offset:offset + CHUNK_WIDTH], 36) + decoded.extend(((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF)) + + raw = zlib.decompress(bytes(decoded[:byte_length]), -15) + payload = json.loads(raw.decode('utf-8')) + theme_slug = slugify(payload.get('n', 'opencode-theme')) + + return { + 'theme_slug': theme_slug, + 'theme_file': { + '$schema': SCHEMA_URL, + 'theme': payload['t'], + }, + } + + +def decode_compact_payload(theme_name: str, encoded: str) -> dict: + if not theme_name: + raise SystemExit('theme name is required for theme install payloads') + + padding = '=' * ((4 - len(encoded) % 4) % 4) + compressed = base64.urlsafe_b64decode(encoded + padding) + raw = zlib.decompress(compressed, -15) + + if not raw: + raise SystemExit('invalid theme payload') + + palette_size = raw[0] + palette_byte_length = palette_size * 4 + token_bytes = raw[1 + palette_byte_length:] + + if len(token_bytes) != len(TOKEN_NAMES) * 2: + raise SystemExit('invalid token payload size') + + palette = [] + + for index in range(palette_size): + offset = 1 + index * 4 + palette.append(rgba_to_color(raw[offset], raw[offset + 1], raw[offset + 2], raw[offset + 3])) + + theme = {} + + for index, token in enumerate(TOKEN_NAMES): + dark_index = token_bytes[index * 2] + light_index = token_bytes[index * 2 + 1] + theme[token] = { + 'dark': palette[dark_index], + 'light': palette[light_index], + } + + return { + 'theme_slug': slugify(theme_name), + 'theme_file': { + '$schema': SCHEMA_URL, + 'theme': theme, + }, + } + + +def decode_install_payload(theme_name: str, encoded: str) -> dict: + if encoded.startswith(CODEC_PREFIX_OT1): + return decode_ot1_payload(encoded) + + return decode_compact_payload(theme_name, encoded) + + +def install_theme(theme_name: str, encoded: str): + payload = decode_install_payload(theme_name, encoded) + theme_slug = payload['theme_slug'] + theme_file = payload['theme_file'] + + project_root = pathlib.Path.cwd() + opencode_dir = project_root / '.opencode' + themes_dir = opencode_dir / 'themes' + theme_path = themes_dir / f'{theme_slug}.json' + tui_path = opencode_dir / 'tui.json' + + themes_dir.mkdir(parents=True, exist_ok=True) + theme_path.write_text(json.dumps(theme_file, indent=2) + '\n', encoding='utf-8') + + if tui_path.exists(): + try: + tui_data = json.loads(tui_path.read_text(encoding='utf-8')) + except json.JSONDecodeError: + tui_data = {} + else: + tui_data = {} + + if not isinstance(tui_data, dict): + tui_data = {} + + tui_data.setdefault('$schema', TUI_SCHEMA_URL) + tui_data['theme'] = theme_slug + tui_path.write_text(json.dumps(tui_data, indent=2) + '\n', encoding='utf-8') + + print(f'Installed {theme_slug} to {theme_path}') + print(f'Activated project theme in {tui_path}') + + +def normalize_studio_url(url: str) -> str: + parsed = urllib.parse.urlsplit(url.strip()) + + if not parsed.scheme or not parsed.netloc: + raise SystemExit('theme studio URL must be an absolute URL') + + return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path or '/', '', '')) + + +def read_json(path: pathlib.Path): + try: + return json.loads(path.read_text(encoding='utf-8')) + except FileNotFoundError: + raise + except json.JSONDecodeError as error: + raise SystemExit(f'could not parse JSON from {path}: {error}') + + +def read_theme_ref_from_tui(path: pathlib.Path): + data = read_json(path) + + if not isinstance(data, dict): + return None + + theme_ref = data.get('theme') + + if isinstance(theme_ref, str) and theme_ref.strip(): + return theme_ref.strip() + + return None + + +def resolve_theme_reference(project_root: pathlib.Path, explicit_ref: str): + if explicit_ref.strip(): + return explicit_ref.strip(), 'argument' + + config_candidates = [ + project_root / '.opencode' / 'tui.json', + pathlib.Path.home() / '.config' / 'opencode' / 'tui.json', + ] + + for candidate in config_candidates: + if not candidate.exists(): + continue + + theme_ref = read_theme_ref_from_tui(candidate) + + if theme_ref: + return theme_ref, str(candidate) + + raise SystemExit('could not find an active OpenCode theme; pass a theme name or JSON file path explicitly') + + +def build_theme_path_candidates(project_root: pathlib.Path, theme_ref: str): + home = pathlib.Path.home() + candidates = [] + raw = theme_ref.strip() + expanded = pathlib.Path(raw).expanduser() + stem = expanded.stem if expanded.suffix == '.json' else raw + + if expanded.is_absolute(): + candidates.append(expanded) + elif raw.startswith('.') or '/' in raw or raw.endswith('.json'): + candidates.append((project_root / expanded).resolve()) + + if raw.endswith('.json'): + candidates.append(project_root / '.opencode' / 'themes' / expanded.name) + candidates.append(home / '.config' / 'opencode' / 'themes' / expanded.name) + else: + candidates.append(project_root / '.opencode' / 'themes' / f'{stem}.json') + candidates.append(home / '.config' / 'opencode' / 'themes' / f'{stem}.json') + + unique_candidates = [] + seen = set() + + for candidate in candidates: + normalized = str(candidate) + + if normalized in seen: + continue + + seen.add(normalized) + unique_candidates.append(candidate) + + return unique_candidates + + +def resolve_theme_path(project_root: pathlib.Path, theme_ref: str): + candidates = build_theme_path_candidates(project_root, theme_ref) + + for candidate in candidates: + if candidate.exists(): + return candidate + + raise SystemExit( + 'could not locate a local theme JSON file for the active theme; built-in themes are not importable with this command yet' + ) + + +def parse_hex_channel(value: str) -> int: + return int(value, 16) + + +def parse_channel(value: str) -> int: + stripped = value.strip() + + if stripped.endswith('%'): + return round(max(0.0, min(100.0, float(stripped[:-1]))) * 2.55) + + return round(max(0.0, min(255.0, float(stripped)))) + + +def parse_alpha(value: str) -> int: + stripped = value.strip() + + if stripped.endswith('%'): + return round(max(0.0, min(100.0, float(stripped[:-1]))) * 2.55) + + numeric = float(stripped) + + if numeric > 1: + return round(max(0.0, min(255.0, numeric))) + + return round(max(0.0, min(1.0, numeric)) * 255) + + +RGBA_PATTERN = re.compile(r'^rgba?\((.+)\)$', re.IGNORECASE) + + +def parse_color(value: str): + normalized = value.strip().lower() + + if normalized == 'transparent': + return 0, 0, 0, 0 + + if normalized.startswith('#'): + hex_value = normalized[1:] + + if len(hex_value) == 3: + red, green, blue = (parse_hex_channel(channel * 2) for channel in hex_value) + return red, green, blue, 255 + + if len(hex_value) == 4: + red, green, blue, alpha = (parse_hex_channel(channel * 2) for channel in hex_value) + return red, green, blue, alpha + + if len(hex_value) == 6: + return ( + parse_hex_channel(hex_value[0:2]), + parse_hex_channel(hex_value[2:4]), + parse_hex_channel(hex_value[4:6]), + 255, + ) + + if len(hex_value) == 8: + return ( + parse_hex_channel(hex_value[0:2]), + parse_hex_channel(hex_value[2:4]), + parse_hex_channel(hex_value[4:6]), + parse_hex_channel(hex_value[6:8]), + ) + + match = RGBA_PATTERN.match(normalized) + + if match: + parts = [part.strip() for part in match.group(1).split(',')] + + if len(parts) == 3: + red, green, blue = (parse_channel(part) for part in parts) + return red, green, blue, 255 + + if len(parts) == 4: + red, green, blue = (parse_channel(part) for part in parts[:3]) + alpha = parse_alpha(parts[3]) + return red, green, blue, alpha + + raise SystemExit(f'cannot encode unsupported color value: {value}') + + +def normalize_theme_file(data: dict): + if not isinstance(data, dict): + raise SystemExit('theme JSON must be an object') + + raw_theme = data.get('theme') + + if not isinstance(raw_theme, dict): + raise SystemExit('theme JSON must contain a theme object') + + normalized_theme = {} + + for token in TOKEN_NAMES: + value = raw_theme.get(token) + + if isinstance(value, dict): + dark = value.get('dark') + light = value.get('light') + else: + dark = value + light = value + + if not isinstance(dark, str) or not isinstance(light, str): + raise SystemExit(f'theme token {token} must contain color strings for dark and light') + + dark_rgba = parse_color(dark) + light_rgba = parse_color(light) + + normalized_theme[token] = { + 'dark': rgba_to_color(*dark_rgba), + 'light': rgba_to_color(*light_rgba), + } + + return { + '$schema': SCHEMA_URL, + 'theme': normalized_theme, + } + + +def encode_theme_payload(theme_file: dict): + palette_indexes = {} + palette_bytes = [] + token_indexes = [] + + for token in TOKEN_NAMES: + for mode in ('dark', 'light'): + color_value = theme_file['theme'][token][mode] + rgba = parse_color(color_value) + color_key = rgba_to_color(*rgba) + palette_index = palette_indexes.get(color_key) + + if palette_index is None: + if len(palette_indexes) >= 255: + raise SystemExit('theme uses too many unique colors for a shareable import payload') + + palette_index = len(palette_indexes) + palette_indexes[color_key] = palette_index + palette_bytes.extend(rgba) + + token_indexes.append(palette_index) + + payload = bytes([len(palette_indexes), *palette_bytes, *token_indexes]) + compressed = zlib.compress(payload, level=9, wbits=-15) + + return base64.urlsafe_b64encode(compressed).decode('ascii').rstrip('=') + + +def build_share_url(studio_url: str, theme_slug: str, encoded_payload: str): + parsed = urllib.parse.urlsplit(studio_url) + query = urllib.parse.urlencode({theme_slug: encoded_payload}) + + return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, '')) + + +def open_url(url: str): + for command in ('open', 'xdg-open'): + executable = shutil.which(command) + + if not executable: + continue + + subprocess.run([executable, url], check=False) + return True + + return False + + +def import_theme(studio_url: str, theme_ref: str): + normalized_studio_url = normalize_studio_url(studio_url) + project_root = pathlib.Path.cwd() + resolved_theme_ref, source = resolve_theme_reference(project_root, theme_ref) + theme_path = resolve_theme_path(project_root, resolved_theme_ref) + theme_slug = slugify(theme_path.stem) + theme_data = normalize_theme_file(read_json(theme_path)) + encoded_payload = encode_theme_payload(theme_data) + share_url = build_share_url(normalized_studio_url, theme_slug, encoded_payload) + opened = open_url(share_url) + + print(f'Resolved theme from {source}: {theme_path}') + print(share_url) + + if opened: + print('Opened Theme Studio in your browser.') + else: + print('Could not auto-open a browser. Copy the URL above manually.') + + +mode = sys.argv[1] if len(sys.argv) > 1 else '' +args = sys.argv[2:] + +if mode == 'install': + if len(args) == 1: + install_theme('', args[0]) + elif len(args) >= 2: + install_theme(args[0], args[1]) + else: + raise SystemExit('usage: ... install ') +elif mode == 'import': + if not args: + raise SystemExit('usage: ... import [theme-name-or-path]') + + import_theme(args[0], args[1] if len(args) >= 2 else '') +else: + raise SystemExit(f'unknown mode: {mode}') +PY diff --git a/public/install.sh b/public/install.sh deleted file mode 100755 index 82b8abd..0000000 --- a/public/install.sh +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env sh -set -eu - -if [ "$#" -lt 1 ]; then - printf '%s\n' "usage: curl -fsSL | bash -s -- " - printf '%s\n' "legacy usage: curl -fsSL | bash -s -- " - exit 1 -fi - -if ! command -v python3 >/dev/null 2>&1; then - printf '%s\n' "python3 is required to install this theme" - exit 1 -fi - -if [ "$#" -ge 2 ]; then - THEME_NAME="$1" - ENCODED_THEME="$2" -else - THEME_NAME="" - ENCODED_THEME="$1" -fi - -python3 - "$THEME_NAME" "$ENCODED_THEME" <<'PY' -import base64 -import json -import pathlib -import re -import sys -import zlib - -SCHEMA_URL = 'https://opencode.ai/theme.json' -TUI_SCHEMA_URL = 'https://opencode.ai/tui.json' -CODEC_PREFIX_OT1 = 'ot1' -LENGTH_WIDTH = 5 -CHUNK_WIDTH = 5 -TOKEN_NAMES = [ - 'primary', - 'secondary', - 'accent', - 'error', - 'warning', - 'success', - 'info', - 'text', - 'textMuted', - 'selectedListItemText', - 'background', - 'backgroundPanel', - 'backgroundElement', - 'backgroundMenu', - 'border', - 'borderActive', - 'borderSubtle', - 'diffAdded', - 'diffRemoved', - 'diffContext', - 'diffHunkHeader', - 'diffHighlightAdded', - 'diffHighlightRemoved', - 'diffAddedBg', - 'diffRemovedBg', - 'diffContextBg', - 'diffLineNumber', - 'diffAddedLineNumberBg', - 'diffRemovedLineNumberBg', - 'markdownText', - 'markdownHeading', - 'markdownLink', - 'markdownLinkText', - 'markdownCode', - 'markdownBlockQuote', - 'markdownEmph', - 'markdownStrong', - 'markdownHorizontalRule', - 'markdownListItem', - 'markdownListEnumeration', - 'markdownImage', - 'markdownImageText', - 'markdownCodeBlock', - 'syntaxComment', - 'syntaxKeyword', - 'syntaxFunction', - 'syntaxVariable', - 'syntaxString', - 'syntaxNumber', - 'syntaxType', - 'syntaxOperator', - 'syntaxPunctuation', -] - - -def slugify(value: str) -> str: - cleaned = re.sub(r'[^a-z0-9]+', '-', value.strip().lower()) - return cleaned.strip('-') or 'opencode-theme' - - -def rgba_to_color(red: int, green: int, blue: int, alpha: int) -> str: - if alpha <= 0: - return 'transparent' - - if alpha >= 255: - return f'#{red:02x}{green:02x}{blue:02x}' - - return f'#{red:02x}{green:02x}{blue:02x}{alpha:02x}' - - -def decode_ot1_payload(encoded: str) -> dict: - body = encoded[len(CODEC_PREFIX_OT1):] - byte_length = int(body[:LENGTH_WIDTH], 36) - chunk_data = body[LENGTH_WIDTH:] - - if len(chunk_data) % CHUNK_WIDTH != 0: - raise SystemExit('invalid ot1 theme payload body') - - decoded = bytearray() - - for offset in range(0, len(chunk_data), CHUNK_WIDTH): - value = int(chunk_data[offset:offset + CHUNK_WIDTH], 36) - decoded.extend(((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF)) - - raw = zlib.decompress(bytes(decoded[:byte_length]), -15) - payload = json.loads(raw.decode('utf-8')) - theme_slug = slugify(payload.get('n', 'opencode-theme')) - - return { - 'theme_slug': theme_slug, - 'theme_file': { - '$schema': SCHEMA_URL, - 'theme': payload['t'], - }, - } - - -def decode_compact_payload(theme_name: str, encoded: str) -> dict: - if not theme_name: - raise SystemExit('theme name is required for theme install payloads') - - padding = '=' * ((4 - len(encoded) % 4) % 4) - compressed = base64.urlsafe_b64decode(encoded + padding) - raw = zlib.decompress(compressed, -15) - - if not raw: - raise SystemExit('invalid theme payload') - - palette_size = raw[0] - palette_byte_length = palette_size * 4 - token_bytes = raw[1 + palette_byte_length:] - - if len(token_bytes) != len(TOKEN_NAMES) * 2: - raise SystemExit('invalid token payload size') - - palette = [] - - for index in range(palette_size): - offset = 1 + index * 4 - palette.append(rgba_to_color(raw[offset], raw[offset + 1], raw[offset + 2], raw[offset + 3])) - - theme = {} - - for index, token in enumerate(TOKEN_NAMES): - dark_index = token_bytes[index * 2] - light_index = token_bytes[index * 2 + 1] - theme[token] = { - 'dark': palette[dark_index], - 'light': palette[light_index], - } - - return { - 'theme_slug': slugify(theme_name), - 'theme_file': { - '$schema': SCHEMA_URL, - 'theme': theme, - }, - } - - -def decode_payload(theme_name: str, encoded: str) -> dict: - if encoded.startswith(CODEC_PREFIX_OT1): - return decode_ot1_payload(encoded) - - return decode_compact_payload(theme_name, encoded) - - -payload = decode_payload(sys.argv[1], sys.argv[2]) -theme_slug = payload['theme_slug'] -theme_file = payload['theme_file'] - -project_root = pathlib.Path.cwd() -opencode_dir = project_root / '.opencode' -themes_dir = opencode_dir / 'themes' -theme_path = themes_dir / f'{theme_slug}.json' -tui_path = opencode_dir / 'tui.json' - -themes_dir.mkdir(parents=True, exist_ok=True) -theme_path.write_text(json.dumps(theme_file, indent=2) + '\n', encoding='utf-8') - -if tui_path.exists(): - try: - tui_data = json.loads(tui_path.read_text(encoding='utf-8')) - except json.JSONDecodeError: - tui_data = {} -else: - tui_data = {} - -if not isinstance(tui_data, dict): - tui_data = {} - -tui_data.setdefault('$schema', TUI_SCHEMA_URL) -tui_data['theme'] = theme_slug -tui_path.write_text(json.dumps(tui_data, indent=2) + '\n', encoding='utf-8') - -print(f'Installed {theme_slug} to {theme_path}') -print(f'Activated project theme in {tui_path}') -PY diff --git a/public/opencode-theme-studio-480.mov b/public/opencode-theme-studio-480.mov new file mode 100644 index 0000000..c01e5fb Binary files /dev/null and b/public/opencode-theme-studio-480.mov differ diff --git a/src/app/ThemeEditorPage.test.tsx b/src/app/ThemeEditorPage.test.tsx index 401dd5f..e853239 100644 --- a/src/app/ThemeEditorPage.test.tsx +++ b/src/app/ThemeEditorPage.test.tsx @@ -58,21 +58,44 @@ vi.mock('../domain/presets/themePresets', () => ({ }), extractStablePaletteFromThemes: () => ['#220033', '#445566', '#778899'], extractPaletteFromThemeTokens: () => ['#221122', '#774455', '#ddbbee'], - remixThemePreset: (preset: unknown) => preset, + remixThemePreset: (preset: { name?: string }, options?: { remixStrength?: string }) => ({ + ...preset, + name: `${preset.name ?? 'Preset'} (${options?.remixStrength ?? 'balanced'})`, + }), })) vi.mock('../features/editor/ThemePresetPicker', () => ({ - ThemePresetPicker: ({ onApplyPreset }: { onApplyPreset: (preset: { id: string }) => void }) => ( + ThemePresetPicker: ({ + onApplyPreset, + onRemixSelectedPreset, + onUndoSelectedPreset, + canUndoSelectedPreset, + selectedPresetPreview, + }: { + onApplyPreset: (preset: { id: string; name: string; palette: string[] }) => void + onRemixSelectedPreset: (strength: 'subtle' | 'balanced' | 'wild') => void + onUndoSelectedPreset: () => void + canUndoSelectedPreset: boolean + selectedPresetPreview?: { name?: string } | null + }) => (
preset picker + {selectedPresetPreview?.name ?? 'none'} + {String(canUndoSelectedPreset)} + +
), })) @@ -83,21 +106,42 @@ vi.mock('../features/editor/SemanticColorEditor', () => ({ randomPalette, onChange, onRandomize, + onShuffleRandomize, + onUndoGeneratedPalette, + onUndoShuffleRandomize, + canUndoGeneratedPalette, + canUndoShuffleRandomize, onChangeRandomPaletteColor, }: { semanticGroups: Record randomPalette: string[] onChange: (group: 'canvas', value: string) => void onRandomize: () => void + onShuffleRandomize: (strength: 'subtle' | 'balanced' | 'wild') => void + onUndoGeneratedPalette: () => void + onUndoShuffleRandomize: () => void + canUndoGeneratedPalette: boolean + canUndoShuffleRandomize: boolean onChangeRandomPaletteColor: (index: number, value: string) => void }) => (
{semanticGroups.canvas} {randomPalette.length} {randomPalette.join(',')} + {String(canUndoGeneratedPalette)} + {String(canUndoShuffleRandomize)} + + + @@ -270,6 +314,38 @@ describe('ThemeEditorPage', () => { expect(screen.queryByTestId('semantic-editor')).not.toBeInTheDocument() }) + it('supports multi-step preset remix undo and resets undo when the preset changes', () => { + renderPage(undefined, 'persisted') + + fireEvent.click(screen.getByRole('button', { name: 'apply preset' })) + + expect(screen.getByTestId('selected-preset-name')).toHaveTextContent('Aura') + expect(screen.getByTestId('can-undo-preset-remix')).toHaveTextContent('false') + + fireEvent.click(screen.getByRole('button', { name: 'remix preset' })) + expect(screen.getByTestId('selected-preset-name')).toHaveTextContent('Aura (balanced)') + expect(screen.getByTestId('can-undo-preset-remix')).toHaveTextContent('true') + + fireEvent.click(screen.getByRole('button', { name: 'remix preset' })) + expect(screen.getByTestId('selected-preset-name')).toHaveTextContent('Aura (balanced) (balanced)') + + fireEvent.click(screen.getByRole('button', { name: 'undo preset remix' })) + expect(screen.getByTestId('selected-preset-name')).toHaveTextContent('Aura (balanced)') + expect(screen.getByTestId('can-undo-preset-remix')).toHaveTextContent('true') + + fireEvent.click(screen.getByRole('button', { name: 'undo preset remix' })) + expect(screen.getByTestId('selected-preset-name')).toHaveTextContent('Aura') + expect(screen.getByTestId('can-undo-preset-remix')).toHaveTextContent('false') + + fireEvent.click(screen.getByRole('button', { name: 'remix preset' })) + expect(screen.getByTestId('can-undo-preset-remix')).toHaveTextContent('true') + + fireEvent.click(screen.getByRole('button', { name: 'apply preset' })) + expect(screen.getByLabelText('Theme name')).toHaveValue('Aura') + expect(screen.getByTestId('selected-preset-name')).toHaveTextContent('Aura') + expect(screen.getByTestId('can-undo-preset-remix')).toHaveTextContent('false') + }) + it('keeps save actions in the editor tabs without the legacy metadata row', () => { renderPage(undefined, 'persisted') @@ -318,6 +394,55 @@ describe('ThemeEditorPage', () => { expect(screen.getByLabelText('Theme name')).toHaveValue('Edited Randomized') }) + it('tracks generated palette undo separately from shuffle undo', () => { + renderPage(undefined, 'persisted') + + fireEvent.click(screen.getByRole('tab', { name: 'Mixer' })) + + expect(screen.getByTestId('can-undo-generated')).toHaveTextContent('false') + expect(screen.getByTestId('can-undo-shuffle')).toHaveTextContent('false') + + fireEvent.click(screen.getByRole('button', { name: 'generate palette' })) + + expect(screen.getByLabelText('Theme name')).toHaveValue('Randomized') + expect(screen.getByTestId('can-undo-generated')).toHaveTextContent('true') + expect(screen.getByTestId('can-undo-shuffle')).toHaveTextContent('false') + + fireEvent.click(screen.getByRole('button', { name: 'shuffle palette' })) + + expect(screen.getByLabelText('Theme name')).toHaveValue('Randomized') + expect(screen.getByTestId('can-undo-generated')).toHaveTextContent('true') + expect(screen.getByTestId('can-undo-shuffle')).toHaveTextContent('true') + + fireEvent.click(screen.getByRole('button', { name: 'undo shuffled palette' })) + + expect(screen.getByLabelText('Theme name')).toHaveValue('Randomized') + expect(screen.getByTestId('can-undo-generated')).toHaveTextContent('true') + expect(screen.getByTestId('can-undo-shuffle')).toHaveTextContent('false') + + fireEvent.click(screen.getByRole('button', { name: 'undo generated palette' })) + + expect(screen.getByLabelText('Theme name')).toHaveValue('Random Default') + expect(screen.getByTestId('can-undo-generated')).toHaveTextContent('false') + expect(screen.getByTestId('can-undo-shuffle')).toHaveTextContent('false') + }) + + it('resets shuffle undo history after generating a new palette', () => { + renderPage(undefined, 'persisted') + + fireEvent.click(screen.getByRole('tab', { name: 'Mixer' })) + fireEvent.click(screen.getByRole('button', { name: 'generate palette' })) + fireEvent.click(screen.getByRole('button', { name: 'shuffle palette' })) + + expect(screen.getByTestId('can-undo-shuffle')).toHaveTextContent('true') + + fireEvent.click(screen.getByRole('button', { name: 'generate palette' })) + + expect(screen.getByLabelText('Theme name')).toHaveValue('Randomized') + expect(screen.getByTestId('can-undo-generated')).toHaveTextContent('true') + expect(screen.getByTestId('can-undo-shuffle')).toHaveTextContent('false') + }) + it('keeps the generated mixer palette stable across dark and light mode switches', async () => { renderPage(undefined, 'persisted') diff --git a/src/app/ThemeEditorPage.tsx b/src/app/ThemeEditorPage.tsx index 8068bb8..7afeb14 100644 --- a/src/app/ThemeEditorPage.tsx +++ b/src/app/ThemeEditorPage.tsx @@ -21,7 +21,9 @@ import { ThemePresetPicker } from '../features/editor/ThemePresetPicker' import { ThemeActionMenu } from '../features/export/ThemeActionMenu' import { downloadThemeFile } from '../features/export/downloadThemeFile' import { SemanticColorEditor } from '../features/editor/SemanticColorEditor' +import { ThemeImportGuide } from '../features/editor/ThemeImportGuide' import { PreviewSurface } from '../features/preview/PreviewSurface' +import type { ThemeDraft, ThemeTokenName } from '../domain/theme/model' import { selectExportThemeFile, selectResolvedMode, selectSemanticGroupAffectedTokens } from '../state/selectors' import { useThemeDraft, useThemeStoreActions } from '../state/theme-store-hooks' import type { HydratedDraftSource } from '../state/hydrateDraft' @@ -30,6 +32,23 @@ type ThemeEditorPageProps = { startupSource?: HydratedDraftSource | null } +type MixerHistoryEntry = { + draft: ThemeDraft + basicRandomPalette: string[] | null + basicRandomVariationSeed: number | null + manualTokenEdits: Record<'dark' | 'light', ThemeTokenName[]> + selectedPresetOrigin: ThemePreset | null + selectedPresetPreview: ThemePreset | null + selectedPresetRemixHistory: ThemePreset[] +} + +type MixerActionHistory = 'none' | 'generate' | 'shuffle' + +const EMPTY_MANUAL_TOKEN_EDITS = { + dark: [], + light: [], +} satisfies Record<'dark' | 'light', ThemeTokenName[]> + function pickRandomPreset(presets: ThemePreset[]) { if (presets.length === 0) { return null @@ -47,12 +66,16 @@ export function ThemeEditorPage({ startupSource = null }: ThemeEditorPageProps) const [presetToolbarPortalTarget, setPresetToolbarPortalTarget] = useState(null) const [selectedPresetOrigin, setSelectedPresetOrigin] = useState(null) const [selectedPresetPreview, setSelectedPresetPreview] = useState(null) + const [selectedPresetRemixHistory, setSelectedPresetRemixHistory] = useState([]) + const [manualTokenEdits, setManualTokenEdits] = useState>(EMPTY_MANUAL_TOKEN_EDITS) const [basicRandomPalette, setBasicRandomPalette] = useState(null) const [basicRandomVariationSeed, setBasicRandomVariationSeed] = useState(null) + const [generatedPaletteHistory, setGeneratedPaletteHistory] = useState([]) + const [shuffleHistory, setShuffleHistory] = useState([]) const hasInitializedStartupPreset = useRef(false) const { hydrateDraft, setActiveMode, setDraftName, setSemanticGroup, setTokenOverride, resetTokenOverride, replaceModeDraft } = useThemeStoreActions() - const { previewModel, editorSemanticGroups, derivedTokens, resolvedTokens, tokenNames, activeModeThemeFile, combinedThemeFile, themeSlug } = + const { previewModel, editorSemanticGroups, derivedTokens, resolvedTokens, tokenNames, combinedThemeFile, themeSlug } = useThemeEditorViewModel(draft) const stableDraftPalette = useMemo( () => @@ -81,6 +104,8 @@ export function ThemeEditorPage({ startupSource = null }: ThemeEditorPageProps) hasInitializedStartupPreset.current = true setEditorTab('presets') + setGeneratedPaletteHistory([]) + setShuffleHistory([]) const randomPreset = pickRandomPreset(THEME_PRESETS) @@ -92,9 +117,41 @@ export function ThemeEditorPage({ startupSource = null }: ThemeEditorPageProps) setBasicRandomVariationSeed(null) setSelectedPresetOrigin(randomPreset) setSelectedPresetPreview(randomPreset) + setSelectedPresetRemixHistory([]) + setManualTokenEdits(EMPTY_MANUAL_TOKEN_EDITS) hydrateDraft(applyThemePresetToDraft(randomPreset, draft)) }, [draft, hydrateDraft, startupSource]) + function resetManualTokenEdits() { + setManualTokenEdits(EMPTY_MANUAL_TOKEN_EDITS) + } + + function markManualTokenEdit(mode: 'dark' | 'light', token: ThemeTokenName) { + setManualTokenEdits((current) => { + if (current[mode].includes(token)) { + return current + } + + return { + ...current, + [mode]: [...current[mode], token], + } + }) + } + + function clearManualTokenEdit(mode: 'dark' | 'light', token: ThemeTokenName) { + setManualTokenEdits((current) => { + if (!current[mode].includes(token)) { + return current + } + + return { + ...current, + [mode]: current[mode].filter((currentToken) => currentToken !== token), + } + }) + } + function exportMode(mode: 'dark' | 'light') { const themeFile = selectExportThemeFile(draft, mode) @@ -106,10 +163,14 @@ export function ThemeEditorPage({ startupSource = null }: ThemeEditorPageProps) } function applyPreset(preset: ThemePreset) { + setGeneratedPaletteHistory([]) + setShuffleHistory([]) setBasicRandomPalette(null) setBasicRandomVariationSeed(null) setSelectedPresetOrigin(preset) setSelectedPresetPreview(preset) + setSelectedPresetRemixHistory([]) + resetManualTokenEdits() hydrateDraft(applyThemePresetToDraft(preset, draft)) } @@ -122,35 +183,93 @@ export function ThemeEditorPage({ startupSource = null }: ThemeEditorPageProps) remixStrength: strength, }) + setSelectedPresetRemixHistory((current) => [...current.slice(-11), selectedPresetPreview]) + resetManualTokenEdits() setSelectedPresetPreview(remixedPreset) hydrateDraft(applyThemePresetToDraft(remixedPreset, draft)) } function undoPresetRemix() { - if (!selectedPresetOrigin) { + const previousPreset = selectedPresetRemixHistory.at(-1) + + if (!previousPreset) { return } - setSelectedPresetPreview(selectedPresetOrigin) - hydrateDraft(applyThemePresetToDraft(selectedPresetOrigin, draft)) + setSelectedPresetRemixHistory((current) => current.slice(0, -1)) + setSelectedPresetPreview(previousPreset) + resetManualTokenEdits() + hydrateDraft(applyThemePresetToDraft(previousPreset, draft)) + } + + function buildMixerHistoryEntry(): MixerHistoryEntry { + return { + draft, + basicRandomPalette, + basicRandomVariationSeed, + manualTokenEdits, + selectedPresetOrigin, + selectedPresetPreview, + selectedPresetRemixHistory, + } + } + + function restoreMixerHistoryEntry(entry: MixerHistoryEntry) { + setBasicRandomPalette(entry.basicRandomPalette) + setBasicRandomVariationSeed(entry.basicRandomVariationSeed) + setManualTokenEdits(entry.manualTokenEdits) + setSelectedPresetOrigin(entry.selectedPresetOrigin) + setSelectedPresetPreview(entry.selectedPresetPreview) + setSelectedPresetRemixHistory(entry.selectedPresetRemixHistory) + hydrateDraft(entry.draft) } - function applyMixerPalette(palette: string[], variationSeed?: number, name?: string) { + function applyMixerPalette( + palette: string[], + options: { + variationSeed?: number + name?: string + remixStrength?: RemixStrength + history?: MixerActionHistory + resetShuffleHistory?: boolean + } = {}, + ) { + const history = options.history ?? 'none' + + if (history !== 'none') { + const snapshot = buildMixerHistoryEntry() + + if (history === 'generate') { + setGeneratedPaletteHistory((current) => [...current.slice(-11), snapshot]) + } else { + setShuffleHistory((current) => [...current.slice(-11), snapshot]) + } + } + + if (options.resetShuffleHistory) { + setShuffleHistory([]) + } + const darkSelection = createSemanticModeSelectionFromPalette('dark', palette, { - variationSeed, + variationSeed: options.variationSeed, + remixStrength: options.remixStrength, }) const lightSelection = createSemanticModeSelectionFromPalette('light', palette, { - variationSeed, + variationSeed: options.variationSeed, + remixStrength: options.remixStrength, }) + const nextName = options.name ?? darkSelection.name setBasicRandomPalette(palette) setBasicRandomVariationSeed(darkSelection.variationSeed) - setDraftName(name ?? darkSelection.name) + setDraftName(nextName) setSelectedPresetOrigin(null) setSelectedPresetPreview(null) + setSelectedPresetRemixHistory([]) + resetManualTokenEdits() hydrateDraft({ ...draft, - name: name ?? darkSelection.name, + name: nextName, modes: { dark: darkSelection.modeDraft, light: lightSelection.modeDraft, @@ -161,7 +280,47 @@ export function ThemeEditorPage({ startupSource = null }: ThemeEditorPageProps) function randomizeActiveMode() { const selection = createRandomSemanticModeSelection(draft.activeMode) - applyMixerPalette(selection.palette, selection.variationSeed, selection.name) + applyMixerPalette(selection.palette, { + variationSeed: selection.variationSeed, + name: selection.name, + history: 'generate', + resetShuffleHistory: true, + }) + } + + function remixMixerPalette(strength: RemixStrength) { + const palette = selectedPresetPreview?.palette ?? basicRandomPalette ?? stableDraftPalette ?? extractPaletteFromThemeTokens(resolvedTokens) + const variationSeed = (basicRandomVariationSeed ?? Date.now()) ^ Math.floor(Math.random() * 0xffffffff) + + applyMixerPalette(palette, { + variationSeed, + remixStrength: strength, + name: draft.name, + history: 'shuffle', + }) + } + + function undoGeneratedPalette() { + const previousState = generatedPaletteHistory.at(-1) + + if (!previousState) { + return + } + + setGeneratedPaletteHistory((current) => current.slice(0, -1)) + setShuffleHistory([]) + restoreMixerHistoryEntry(previousState) + } + + function undoShufflePalette() { + const previousState = shuffleHistory.at(-1) + + if (!previousState) { + return + } + + setShuffleHistory((current) => current.slice(0, -1)) + restoreMixerHistoryEntry(previousState) } function updateRandomPaletteColor(index: number, value: string) { @@ -173,7 +332,10 @@ export function ThemeEditorPage({ startupSource = null }: ThemeEditorPageProps) const nextPalette = currentPalette.map((color, colorIndex) => (colorIndex === index ? value : color)) - applyMixerPalette(nextPalette, basicRandomVariationSeed ?? undefined) + applyMixerPalette(nextPalette, { + variationSeed: basicRandomVariationSeed ?? undefined, + resetShuffleHistory: true, + }) } function toggleActiveMode() { @@ -254,9 +416,7 @@ export function ThemeEditorPage({ startupSource = null }: ThemeEditorPageProps) selectedPresetId={selectedPresetPreview?.id ?? null} selectedPresetPreview={selectedPresetPreview} canRemixSelectedPreset={Boolean(selectedPresetPreview?.palette)} - canUndoSelectedPreset={Boolean( - selectedPresetOrigin && selectedPresetPreview && selectedPresetOrigin !== selectedPresetPreview, - )} + canUndoSelectedPreset={selectedPresetRemixHistory.length > 0} onApplyPreset={applyPreset} onRemixSelectedPreset={remixSelectedPreset} onUndoSelectedPreset={undoPresetRemix} @@ -267,11 +427,17 @@ export function ThemeEditorPage({ startupSource = null }: ThemeEditorPageProps) semanticGroups={editorSemanticGroups} randomPalette={mixerPalette} onRandomize={randomizeActiveMode} + onShuffleRandomize={remixMixerPalette} + onUndoGeneratedPalette={undoGeneratedPalette} + onUndoShuffleRandomize={undoShufflePalette} + canUndoGeneratedPalette={generatedPaletteHistory.length > 0} + canUndoShuffleRandomize={shuffleHistory.length > 0} onChangeRandomPaletteColor={updateRandomPaletteColor} onChange={(group, value) => { setSemanticGroup(draft.activeMode, group, value) for (const token of selectSemanticGroupAffectedTokens(group)) { + clearManualTokenEdit(draft.activeMode, token) resetTokenOverride(draft.activeMode, token) } }} @@ -281,10 +447,13 @@ export function ThemeEditorPage({ startupSource = null }: ThemeEditorPageProps) resolvedTokens={resolvedTokens} derivedTokens={derivedTokens} overrides={draft.modes[draft.activeMode].tokenOverrides} + manuallyEditedTokens={manualTokenEdits[draft.activeMode]} onChange={(token, value) => { + markManualTokenEdit(draft.activeMode, token) setTokenOverride(draft.activeMode, token, value) }} onReset={(token) => { + clearManualTokenEdit(draft.activeMode, token) resetTokenOverride(draft.activeMode, token) }} /> @@ -297,15 +466,31 @@ export function ThemeEditorPage({ startupSource = null }: ThemeEditorPageProps) onDownloadCombined={exportCombined} /> ) : ( - { - applyJsonModeThemes(draft, tokenNames, modeThemes, replaceModeDraft) - }} - /> +
+
+ + +
+
+

Current theme JSON

+
+ +

+ Edit the full dark and light bundle directly, or paste your current theme JSON here if you do not want to run the import-export script. The JSON updates as you edit. +

+ + { + resetManualTokenEdits() + applyJsonModeThemes(draft, tokenNames, modeThemes, replaceModeDraft) + }} + /> +
+
+
)}
diff --git a/src/app/styles/editor-header.css b/src/app/styles/editor-header.css index 4ea8256..b11260e 100644 --- a/src/app/styles/editor-header.css +++ b/src/app/styles/editor-header.css @@ -79,6 +79,7 @@ align-items: start; width: calc(100% + var(--editor-title-leading-column) + var(--editor-title-leading-gap)); min-width: 0; + margin-top: 10px; margin-left: calc(-1 * (var(--editor-title-leading-column) + var(--editor-title-leading-gap))); padding: 4px 0 10px; border: 1px solid transparent; diff --git a/src/app/useThemeEditorViewModel.ts b/src/app/useThemeEditorViewModel.ts index 3a40d1c..ae67638 100644 --- a/src/app/useThemeEditorViewModel.ts +++ b/src/app/useThemeEditorViewModel.ts @@ -5,7 +5,6 @@ import { selectDerivedMode, selectEditorSemanticGroups, selectExportCombinedThemeFile, - selectExportThemeFile, selectPreviewModel, selectResolvedMode, } from '../state/selectors' @@ -17,7 +16,6 @@ export function useThemeEditorViewModel(draft: ThemeDraft) { const derivedTokens = useMemo(() => selectDerivedMode(draft, draft.activeMode), [draft]) const resolvedTokens = useMemo(() => selectResolvedMode(draft, draft.activeMode), [draft]) const tokenNames = useMemo(() => Object.keys(resolvedTokens) as ThemeTokenName[], [resolvedTokens]) - const activeModeThemeFile = useMemo(() => selectExportThemeFile(draft, draft.activeMode), [draft]) const combinedThemeFile = useMemo(() => selectExportCombinedThemeFile(draft), [draft]) const themeSlug = useMemo(() => buildThemeSlug(draft.name), [draft.name]) const footerStyle = useMemo(() => buildThemeEditorFooterStyle(resolvedTokens), [resolvedTokens]) @@ -28,7 +26,6 @@ export function useThemeEditorViewModel(draft: ThemeDraft) { derivedTokens, resolvedTokens, tokenNames, - activeModeThemeFile, combinedThemeFile, themeSlug, footerStyle, diff --git a/src/domain/presets/themePresets.ts b/src/domain/presets/themePresets.ts index a3caaa4..989d4db 100644 --- a/src/domain/presets/themePresets.ts +++ b/src/domain/presets/themePresets.ts @@ -801,11 +801,13 @@ export function createSemanticModeSelectionFromPalette( options: { fallbackId?: string variationSeed?: number + remixStrength?: RemixStrength } = {}, ) { const normalizedPalette = normalizePalette(palette) const fallbackId = options.fallbackId ?? `basic:${mode}:${normalizedPalette.join('|') || 'generated'}` const variationSeed = options.variationSeed ?? hashString(fallbackId) + const remixStrength = options.remixStrength ?? 'balanced' return { name: buildGeneratedPaletteThemeName({ @@ -814,7 +816,7 @@ export function createSemanticModeSelectionFromPalette( }), palette: normalizedPalette, variationSeed, - modeDraft: createModeDraftFromPalette(normalizedPalette, mode, getModeVariationSeed(variationSeed, mode), 'balanced'), + modeDraft: createModeDraftFromPalette(normalizedPalette, mode, getModeVariationSeed(variationSeed, mode), remixStrength), } } diff --git a/src/features/editor/AdvancedTokenEditor.test.tsx b/src/features/editor/AdvancedTokenEditor.test.tsx index a74709e..4a9acee 100644 --- a/src/features/editor/AdvancedTokenEditor.test.tsx +++ b/src/features/editor/AdvancedTokenEditor.test.tsx @@ -18,13 +18,14 @@ describe('AdvancedTokenEditor', () => { const tokens = createTokens() render( - , + , ) const renderedValues = screen.getAllByRole('textbox').map((input) => (input as HTMLInputElement).value) @@ -32,4 +33,28 @@ describe('AdvancedTokenEditor', () => { expect(renderedValues).toHaveLength(THEME_TOKEN_NAMES.length) expect([...renderedValues].sort()).toEqual(Object.values(tokens).sort()) }) + + it('only shows reset controls for manually edited overrides', () => { + const resolvedTokens = createTokens() + const derivedTokens = createTokens() + + resolvedTokens.primary = '#ff00aa' + + render( + , + ) + + expect(screen.getByRole('button', { name: 'Reset Primary' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Reset Secondary' })).not.toBeInTheDocument() + }) }) diff --git a/src/features/editor/AdvancedTokenEditor.tsx b/src/features/editor/AdvancedTokenEditor.tsx index 675dd9c..2870574 100644 --- a/src/features/editor/AdvancedTokenEditor.tsx +++ b/src/features/editor/AdvancedTokenEditor.tsx @@ -49,6 +49,7 @@ type AdvancedTokenEditorProps = { resolvedTokens: ThemeTokens derivedTokens: ThemeTokens overrides: Partial + manuallyEditedTokens: ThemeTokenName[] onChange: (token: ThemeTokenName, value: string) => void onReset: (token: ThemeTokenName) => void } @@ -59,10 +60,34 @@ function formatTokenLabel(token: ThemeTokenName) { .replace(/^./, (value) => value.toUpperCase()) } +function UndoIcon() { + return ( + + ) +} + export function AdvancedTokenEditor({ resolvedTokens, derivedTokens, overrides, + manuallyEditedTokens, onChange, onReset, }: AdvancedTokenEditorProps) { @@ -82,14 +107,14 @@ export function AdvancedTokenEditor({ const isOverridden = overrideValue !== undefined const isInSyncWithDerived = isOverridden && overrideValue.toLowerCase() === derivedTokens[token].toLowerCase() - const showReset = isOverridden && !isInSyncWithDerived + const showReset = manuallyEditedTokens.includes(token) && isOverridden && !isInSyncWithDerived const colorInputId = `token-color-${token}` return (
diff --git a/src/features/editor/JsonThemeEditor.test.tsx b/src/features/editor/JsonThemeEditor.test.tsx index 09266ea..e095035 100644 --- a/src/features/editor/JsonThemeEditor.test.tsx +++ b/src/features/editor/JsonThemeEditor.test.tsx @@ -44,7 +44,7 @@ describe('JsonThemeEditor', () => { text: '#aabbcc', }), ) - expect(screen.getByRole('status')).toHaveTextContent('Changes apply while the JSON stays valid') + expect(screen.queryByRole('status')).not.toBeInTheDocument() }) it('applies a valid combined edit and resolves defs references', () => { @@ -156,10 +156,15 @@ describe('JsonThemeEditor', () => { ...props.themeFile.theme, text: '#ABC', } - const expectedFormatted = serializeThemeFile(exportThemeFile({ - ...props.themeFile.theme, - text: '#aabbcc', - })).trimEnd() + const expectedFormatted = serializeThemeFile(exportCombinedThemeFile( + { + ...props.themeFile.theme, + text: '#aabbcc', + }, + Object.fromEntries( + Object.entries(props.combinedThemeFile.theme).map(([token, value]) => [token, value.light]), + ) as ThemeTokens, + )).trimEnd() function ControlledEditor() { const [darkTheme, setDarkTheme] = useState(props.themeFile.theme) @@ -172,7 +177,6 @@ describe('JsonThemeEditor', () => { return ( { props.onChange(modeThemes) diff --git a/src/features/editor/JsonThemeEditor.tsx b/src/features/editor/JsonThemeEditor.tsx index 4da2e44..fa7eac9 100644 --- a/src/features/editor/JsonThemeEditor.tsx +++ b/src/features/editor/JsonThemeEditor.tsx @@ -2,14 +2,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { serializeThemeFile, type OpenCodeCombinedThemeFile, - type OpenCodeThemeFile, } from '../../domain/opencode/exportTheme' import type { ThemeMode, ThemeTokenName } from '../../domain/theme/model' import { parseJsonThemeFile, type JsonThemeModeUpdates } from './jsonThemeEditorParser' type JsonThemeEditorProps = { - themeFile: OpenCodeThemeFile combinedThemeFile: OpenCodeCombinedThemeFile tokenNames: ThemeTokenName[] activeMode: ThemeMode @@ -64,22 +62,12 @@ function getCopyButtonA11yLabel(label: string) { export function JsonThemeEditor({ - themeFile, combinedThemeFile, tokenNames, activeMode, onChange, }: JsonThemeEditorProps) { - const [format, setFormat] = useState<'single' | 'combined'>('single') - const formattedTheme = useMemo( - () => - serializeThemeFile( - format === 'combined' - ? combinedThemeFile - : themeFile, - ).trimEnd(), - [combinedThemeFile, format, themeFile], - ) + const formattedTheme = useMemo(() => serializeThemeFile(combinedThemeFile).trimEnd(), [combinedThemeFile]) const [jsonText, setJsonText] = useState(formattedTheme) const [parseError, setParseError] = useState(null) const [isEditing, setIsEditing] = useState(false) @@ -132,7 +120,6 @@ export function JsonThemeEditor({ } setParseError(null) - setFormat(parsed.value.format) onChange(parsed.value.modeThemes) } @@ -150,10 +137,12 @@ export function JsonThemeEditor({ } return ( -
-

- {parseError ?? 'Changes apply while the JSON stays valid'} -

+
+ {parseError ? ( +

+ {parseError} +

+ ) : null}
-
+
) } diff --git a/src/features/editor/SemanticColorEditor.test.tsx b/src/features/editor/SemanticColorEditor.test.tsx index bcf4227..a8d44f9 100644 --- a/src/features/editor/SemanticColorEditor.test.tsx +++ b/src/features/editor/SemanticColorEditor.test.tsx @@ -16,14 +16,19 @@ describe('SemanticColorEditor', () => { const onChange = vi.fn() render( - , + , ) const canvasChoices = within(screen.getByLabelText('canvas palette choices')).getAllByRole('button') @@ -36,14 +41,19 @@ describe('SemanticColorEditor', () => { const draft = createDefaultThemeDraft() render( - , + , ) const canvasSuggestionColors = getSuggestionColors('canvas palette choices') @@ -62,14 +72,19 @@ describe('SemanticColorEditor', () => { } render( - , + , ) const successColors = getSuggestionColors('success palette choices') @@ -86,14 +101,19 @@ describe('SemanticColorEditor', () => { const onChange = vi.fn() render( - , + , ) fireEvent.change(screen.getByLabelText('canvas color'), { @@ -111,4 +131,40 @@ describe('SemanticColorEditor', () => { expect(onChange).toHaveBeenNthCalledWith(1, 'canvas', '#123456') expect(onChange).toHaveBeenNthCalledWith(2, 'success', '#4fd675') }) + + it('shows generation guidance and mixer shuffle controls', () => { + const draft = createDefaultThemeDraft() + const onRandomize = vi.fn() + const onShuffleRandomize = vi.fn() + const onUndoGeneratedPalette = vi.fn() + const onUndoShuffleRandomize = vi.fn() + + render( + , + ) + + expect(screen.getByText(/balances contrast, luminosity, and color relationships/i)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Generate' })) + fireEvent.click(screen.getByRole('button', { name: 'Undo generated palette' })) + fireEvent.click(screen.getByRole('button', { name: 'Balanced shuffle' })) + fireEvent.click(screen.getByRole('button', { name: 'Undo palette shuffle' })) + + expect(onRandomize).toHaveBeenCalledOnce() + expect(onUndoGeneratedPalette).toHaveBeenCalledOnce() + expect(onShuffleRandomize).toHaveBeenCalledWith('balanced') + expect(onUndoShuffleRandomize).toHaveBeenCalledOnce() + }) }) diff --git a/src/features/editor/SemanticColorEditor.tsx b/src/features/editor/SemanticColorEditor.tsx index e766cab..a2a90c0 100644 --- a/src/features/editor/SemanticColorEditor.tsx +++ b/src/features/editor/SemanticColorEditor.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import type { RemixStrength } from '../../domain/presets/themePresets' import type { SemanticGroupName, ThemeMode } from '../../domain/theme/model' import { colorToHsl, @@ -39,9 +40,20 @@ type SemanticColorEditorProps = { randomPalette: string[] onChange: (group: SemanticGroupName, value: string) => void onRandomize: () => void + onShuffleRandomize: (strength: RemixStrength) => void + onUndoGeneratedPalette: () => void + onUndoShuffleRandomize: () => void + canUndoGeneratedPalette: boolean + canUndoShuffleRandomize: boolean onChangeRandomPaletteColor: (index: number, value: string) => void } +const REMIX_ACTIONS: Array<{ strength: RemixStrength; label: string; pips: 2 | 4 | 6 }> = [ + { strength: 'subtle', label: 'Soft shuffle', pips: 2 }, + { strength: 'balanced', label: 'Balanced shuffle', pips: 4 }, + { strength: 'wild', label: 'Wild shuffle', pips: 6 }, +] + const SUGGESTION_COUNT = 5 const FOUNDATION_LIGHTNESS_STOPS = { @@ -249,6 +261,47 @@ function sortPaletteByLightness(palette: string[]) { }) } +function DiceIcon({ pips }: { pips: 2 | 4 | 6 }) { + const pipMap: Record<2 | 4 | 6, Array<[number, number]>> = { + 2: [ + [8.5, 8.5], + [15.5, 15.5], + ], + 4: [ + [8.5, 8.5], + [15.5, 8.5], + [8.5, 15.5], + [15.5, 15.5], + ], + 6: [ + [8.5, 8], + [15.5, 8], + [8.5, 12], + [15.5, 12], + [8.5, 16], + [15.5, 16], + ], + } + + return ( + + ) +} + +function UndoIcon() { + return ( + + ) +} + function buildFoundationSuggestions( group: Exclude, palette: string[], @@ -356,6 +409,11 @@ export function SemanticColorEditor({ randomPalette, onChange, onRandomize, + onShuffleRandomize, + onUndoGeneratedPalette, + onUndoShuffleRandomize, + canUndoGeneratedPalette, + canUndoShuffleRandomize, onChangeRandomPaletteColor, }: SemanticColorEditorProps) { const suggestionMap = useMemo( @@ -365,41 +423,115 @@ export function SemanticColorEditor({ ) as Record, [activeMode, randomPalette, semanticGroups], ) + const randomPaletteRows = useMemo(() => { + if (randomPalette.length === 0) { + return [] as Array> + } + + if (randomPalette.length < 4) { + return [randomPalette.map((color, index) => ({ color, index }))] + } + + const splitIndex = Math.ceil(randomPalette.length / 2) + + return [ + randomPalette.slice(0, splitIndex).map((color, index) => ({ color, index })), + randomPalette.slice(splitIndex).map((color, index) => ({ color, index: index + splitIndex })), + ] + }, [randomPalette]) return (
-

Palette

- -
- -
+

Color Palette

- {randomPalette.length > 0 ? ( -
- {randomPalette.map((color, index) => ( -
diff --git a/src/features/editor/ThemeImportGuide.tsx b/src/features/editor/ThemeImportGuide.tsx new file mode 100644 index 0000000..f78464d --- /dev/null +++ b/src/features/editor/ThemeImportGuide.tsx @@ -0,0 +1,118 @@ +import { useMemo, useState } from 'react' + +function buildImportScriptUrl() { + const basePath = window.location.pathname.endsWith('/') ? window.location.pathname : `${window.location.pathname}/` + + return new URL('import-export.sh', new URL(basePath, window.location.origin)).toString() +} + +function buildStudioUrl() { + const url = new URL(window.location.href) + + url.search = '' + url.hash = '' + + return url.toString() +} + +function CopyIcon() { + return ( + + ) +} + +function getCopyButtonState(label: string) { + if (label === 'Copied') { + return 'copied' + } + + if (label === 'Unavailable') { + return 'unavailable' + } + + return 'idle' +} + +function getCopyButtonA11yLabel(label: string) { + if (label === 'Copied') { + return 'Import command copied' + } + + if (label === 'Unavailable') { + return 'Copy unavailable' + } + + return 'Copy import command' +} + +export function ThemeImportGuide() { + const [copyLabel, setCopyLabel] = useState('Copy') + const importCommand = useMemo(() => { + return `curl -fsSL ${buildImportScriptUrl()} | bash -s -- import '${buildStudioUrl()}'` + }, []) + + async function copyImportCommand() { + try { + await navigator.clipboard.writeText(importCommand) + setCopyLabel('Copied') + + window.setTimeout(() => { + setCopyLabel('Copy') + }, 1600) + } catch { + setCopyLabel('Unavailable') + } + } + + return ( +
+
+

Import from OpenCode

+
+ +

+ Open your current local OpenCode theme in Theme Studio. +

+ +
    +
  1. + In OpenCode, type ! +
  2. +
  3. Paste the command below and press Enter
  4. +
  5. Theme Studio opens with the theme loaded
  6. +
+ +
+ {importCommand} + +
+
+ ) +} diff --git a/src/features/editor/ThemePresetPicker.test.tsx b/src/features/editor/ThemePresetPicker.test.tsx index a27c3cb..5c72bc3 100644 --- a/src/features/editor/ThemePresetPicker.test.tsx +++ b/src/features/editor/ThemePresetPicker.test.tsx @@ -71,6 +71,7 @@ describe('ThemePresetPicker', () => { it('shows top search, style filter, and compact random controls', () => { const { container } = renderPicker() const filterCombo = container.querySelector('.theme-preset-filter-combo') + const toolbarControls = container.querySelector('.theme-preset-toolbar-controls') expect(screen.getByRole('button', { name: 'Random' })).toBeInTheDocument() expect(screen.getByLabelText('Search presets')).toBeInTheDocument() @@ -78,7 +79,7 @@ describe('ThemePresetPicker', () => { expect(screen.getByPlaceholderText('Search 2 presets')).toBeInTheDocument() expect(screen.queryByText('Generate first, then fine-tune once the preview feels close.')).not.toBeInTheDocument() expect(filterCombo?.children[1]).toHaveClass('theme-preset-style-filter') - expect(filterCombo?.children[2]).toHaveClass('theme-preset-random-slot') + expect(toolbarControls?.children[1]).toHaveClass('theme-preset-random-slot') }) it('lists all styles, built-ins, then community styles in the filter dropdown', () => { @@ -93,11 +94,12 @@ describe('ThemePresetPicker', () => { expect(options).toEqual([ { label: 'All styles', value: 'all-styles', disabled: false }, - { label: '----------------', value: 'opencode-divider', disabled: true }, - { label: 'OpenCode built-ins', value: 'opencode-builtins', disabled: false }, - { label: '----------------', value: 'community-divider', disabled: true }, + { label: 'OpenCode', value: 'opencode-builtins', disabled: false }, { label: 'Warm', value: 'warm', disabled: false }, ]) + + expect(styleSelect.querySelector('hr.opencode-divider')).toBeInTheDocument() + expect(styleSelect.querySelector('hr.community-divider')).toBeInTheDocument() }) it('applies a random visible preset from the filtered list', () => { @@ -125,6 +127,15 @@ describe('ThemePresetPicker', () => { expect(screen.getByText('Aura')).toBeInTheDocument() }) + it('auto-expands built-in presets when a built-in preset is selected', () => { + renderPicker({ + selectedPresetId: 'aura', + selectedPresetPreview: buildPreset(), + }) + + expect(screen.getByText('Aura')).toBeInTheDocument() + }) + it('filters community presets without showing source tokens on cards', () => { renderPicker() diff --git a/src/features/editor/ThemePresetPicker.tsx b/src/features/editor/ThemePresetPicker.tsx index 07dc2e7..9ce259d 100644 --- a/src/features/editor/ThemePresetPicker.tsx +++ b/src/features/editor/ThemePresetPicker.tsx @@ -28,12 +28,13 @@ type PresetStyleOption = { value: string label: string disabled?: boolean + kind?: 'option' | 'divider' } const ALL_STYLE_FILTER = 'all-styles' const BUILTIN_GROUP_ID = 'opencode' const BUILTIN_STYLE_FILTER = 'opencode-builtins' -const BUILTIN_STYLE_FILTER_LABEL = 'OpenCode built-ins' +const BUILTIN_STYLE_FILTER_LABEL = 'OpenCode' const BUILTIN_STYLE_FILTER_DIVIDER = 'opencode-divider' const COMMUNITY_STYLE_FILTER_DIVIDER = 'community-divider' @@ -103,6 +104,16 @@ function getPresetStyleTokens(preset: ThemePreset) { return getPresetCategoryTokens(preset.metaLabel, preset.tags).map(normalizePresetStyleValue) } +function getGroupIdForPreset(preset: ThemePreset) { + if (preset.source === 'opencode') { + return BUILTIN_GROUP_ID + } + + const title = getPresetStyleLabel(preset) ?? 'Other' + + return `category:${normalizePresetStyleValue(title)}` +} + function buildThumbnailStyle(tokens: ThemeTokens) { return { ['--thumb-bg' as string]: tokens.background, @@ -242,6 +253,17 @@ export function ThemePresetPicker(props: ThemePresetPickerProps) { const scrollAnimationFrameRef = useRef(null) const presetItemRefs = useRef>({}) const normalizedSearchValue = searchValue.trim().toLowerCase() + const selectedPreset = useMemo(() => { + if (selectedPresetPreview) { + return selectedPresetPreview + } + + if (!selectedPresetId) { + return null + } + + return presets.find((preset) => preset.id === selectedPresetId) ?? null + }, [presets, selectedPresetId, selectedPresetPreview]) useEffect(() => { return () => { @@ -286,13 +308,13 @@ export function ThemePresetPicker(props: ThemePresetPickerProps) { if (hasBuiltinPresets) { nextOptions.push( - { value: BUILTIN_STYLE_FILTER_DIVIDER, label: '----------------', disabled: true }, + { value: BUILTIN_STYLE_FILTER_DIVIDER, label: '', disabled: true, kind: 'divider' }, { value: BUILTIN_STYLE_FILTER, label: BUILTIN_STYLE_FILTER_LABEL }, ) } if (communityStyleOptions.length > 0) { - nextOptions.push({ value: COMMUNITY_STYLE_FILTER_DIVIDER, label: '----------------', disabled: true }) + nextOptions.push({ value: COMMUNITY_STYLE_FILTER_DIVIDER, label: '', disabled: true, kind: 'divider' }) nextOptions.push(...communityStyleOptions) } @@ -314,6 +336,26 @@ export function ThemePresetPicker(props: ThemePresetPickerProps) { } }, [selectableStyleOptions, styleFilter]) + useEffect(() => { + if (!selectedPreset) { + return + } + + const groupId = getGroupIdForPreset(selectedPreset) + + setCollapsedGroupIds((current) => { + if (!current.has(groupId)) { + return current + } + + const next = new Set(current) + + next.delete(groupId) + + return next + }) + }, [selectedPreset]) + const styleScopedPresets = useMemo(() => { const hasStyleFilter = styleFilter !== ALL_STYLE_FILTER @@ -433,16 +475,6 @@ export function ThemePresetPicker(props: ThemePresetPickerProps) { }) } - function getGroupIdForPreset(preset: ThemePreset) { - if (preset.source === 'opencode') { - return BUILTIN_GROUP_ID - } - - const title = getPresetStyleLabel(preset) ?? 'Other' - - return `category:${normalizePresetStyleValue(title)}` - } - function scrollPresetIntoView(presetId: string) { requestAnimationFrame(() => { const presetItem = presetItemRefs.current[presetId] @@ -539,40 +571,46 @@ export function ThemePresetPicker(props: ThemePresetPickerProps) { const toolbar = (
-
-
- { - setSearchValue(event.target.value) - }} - /> +
+
+
+ { + setSearchValue(event.target.value) + }} + /> +
+ +
- -
-
@@ -648,12 +686,12 @@ export function ThemePresetPicker(props: ThemePresetPickerProps) { {isSelected ? (
-
+
{REMIX_ACTIONS.map((action) => ( ))} +
- -
) : null}
diff --git a/src/features/editor/styles/color-controls.css b/src/features/editor/styles/color-controls.css index 601ff5c..670e8a3 100644 --- a/src/features/editor/styles/color-controls.css +++ b/src/features/editor/styles/color-controls.css @@ -35,8 +35,10 @@ } .advanced-row { - grid-template-columns: 28px minmax(0, 1fr) max-content; - column-gap: 8px; + grid-template-columns: 46px minmax(0, 1fr) max-content; + column-gap: 10px; + min-height: 34px; + padding: 0; } .color-row-copy { @@ -115,9 +117,10 @@ .advanced-color-cell { position: relative; - display: block; + display: flex; + align-items: center; justify-self: start; - width: 28px; + width: 46px; height: 28px; overflow: visible; } @@ -126,6 +129,11 @@ justify-self: start; } +.advanced-color-cell .advanced-color-well { + width: 46px; + height: 22px; +} + .color-value { width: 13ch; min-width: 13ch; @@ -194,28 +202,29 @@ .advanced-reset-icon { position: absolute; - left: -18px; + left: -19px; top: 50%; - width: 14px; - height: 14px; + width: 18px; + height: 18px; padding: 0; border: 0; border-radius: 999px; - background: var(--advanced-reset-bg); - color: var(--advanced-reset-color); - font-size: 0.69rem; - font-weight: 600; + background: transparent; + color: color-mix(in srgb, var(--text-soft) 74%, var(--text-muted) 26%); line-height: 1; transform: translateY(-50%); - box-shadow: none; - opacity: 0.88; + opacity: 0.96; transition: background-color 150ms ease, color 150ms ease, opacity 150ms ease, box-shadow 150ms ease; } .advanced-row:hover .advanced-reset-icon, .advanced-reset-icon:hover { - background: var(--advanced-reset-bg-hover); - color: var(--advanced-reset-color); - box-shadow: none; + color: var(--text); opacity: 1; } + +.advanced-reset-icon-glyph { + width: 16px; + height: 16px; + flex: 0 0 auto; +} diff --git a/src/features/editor/styles/semantic-editor.css b/src/features/editor/styles/semantic-editor.css index bc147f6..9f88c57 100644 --- a/src/features/editor/styles/semantic-editor.css +++ b/src/features/editor/styles/semantic-editor.css @@ -17,20 +17,34 @@ padding-bottom: 12px; } +.semantic-editor-toolbar-caption { + max-width: 28rem; + margin-bottom: 6px; +} + .semantic-editor-toolbar-header { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 12px; width: 100%; } -.semantic-editor-actions { - display: inline-flex; - align-items: center; - justify-content: flex-start; +.semantic-generated-palette-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 10px 12px; + margin-top: 8px; + min-width: 0; +} + +.semantic-generated-controls { + --semantic-generated-control-width: 136px; + display: grid; gap: 8px; - flex: 0 0 auto; + width: var(--semantic-generated-control-width); + min-width: var(--semantic-generated-control-width); } .semantic-random-button { @@ -58,6 +72,67 @@ background: color-mix(in srgb, var(--download-control-accent, var(--accent)) 12%, var(--button-bg)); } +.semantic-random-pill-group { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + width: 100%; + min-height: 32px; + border-radius: 999px; + background: var(--swatch-frame-bg); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.16), + inset 0 -1px 0 rgba(0, 0, 0, 0.16), + 0 0 0 1px var(--hairline-soft); + overflow: hidden; +} + +.semantic-random-generate-group { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.semantic-random-group-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 32px; + padding: 0; + border: 0; + background: transparent; + color: var(--text-soft); + transition: background-color 150ms ease, color 150ms ease, transform 150ms ease, opacity 150ms ease; +} + +.semantic-random-group-button + .semantic-random-group-button { + box-shadow: -1px 0 0 var(--hairline-soft); +} + +.semantic-random-generate-button { + grid-column: span 3; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.semantic-random-group-button:hover:not(:disabled) { + background: color-mix(in srgb, var(--swatch-frame-bg) 82%, var(--accent) 18%); + color: var(--text); +} + +.semantic-random-group-button:active:not(:disabled) { + transform: translateY(1px); +} + +.semantic-random-group-button:disabled { + opacity: 0.42; +} + +.semantic-random-icon { + width: 16px; + height: 16px; + flex: 0 0 auto; +} + .semantic-generated-palette { display: flex; flex-wrap: wrap; @@ -68,12 +143,26 @@ max-width: 100%; } +.semantic-generated-palette-balanced { + display: grid; + justify-content: start; + gap: 8px; + padding-top: 1px; +} + +.semantic-generated-palette-line { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + .semantic-generated-palette-chip { position: relative; display: block; min-width: 0; - width: 28px; - height: 28px; + width: 32px; + height: 32px; border-radius: 999px; background: var(--swatch-frame-bg); cursor: pointer; @@ -83,6 +172,10 @@ 0 0 0 1px var(--hairline-soft); } +.semantic-generated-palette-chip-pill { + width: 46px; +} + .semantic-generated-palette-well { justify-self: center; } @@ -101,3 +194,14 @@ gap: 14px; align-content: start; } + +@media (max-width: 720px) { + .semantic-generated-palette-row { + grid-template-columns: 1fr; + } + + .semantic-generated-controls { + width: 100%; + min-width: 0; + } +} diff --git a/src/features/editor/styles/theme-presets.css b/src/features/editor/styles/theme-presets.css index d151443..1201b54 100644 --- a/src/features/editor/styles/theme-presets.css +++ b/src/features/editor/styles/theme-presets.css @@ -25,42 +25,44 @@ content: none; } +.theme-preset-toolbar-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} + .theme-preset-filter-combo { position: relative; display: grid; - grid-template-columns: minmax(15ch, 2fr) minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: minmax(12rem, 1.45fr) minmax(8rem, 0.55fr); align-items: center; z-index: 1; min-width: 0; - width: calc(100% + 24px); - margin-inline: -12px; - border: 1px solid color-mix(in srgb, var(--field-border) 62%, transparent); - border-radius: 999px; - background: color-mix(in srgb, var(--panel-recessed) 46%, transparent); - box-shadow: inset 0 1px 0 color-mix(in srgb, white 10%, transparent); + width: 100%; + border: 1px solid var(--json-editor-border); + border-radius: 12px; + background: var(--json-editor-bg); + box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; overflow: hidden; isolation: isolate; - transition: border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease; + transition: border-color 150ms ease, background-color 150ms ease; } .theme-preset-filter-combo:hover { - border-color: color-mix(in srgb, var(--field-border) 56%, var(--accent) 20%); - background: color-mix(in srgb, var(--panel-recessed) 50%, transparent); - box-shadow: inset 0 1px 0 color-mix(in srgb, white 12%, transparent); + border-color: color-mix(in srgb, var(--json-editor-border) 72%, var(--accent) 28%); } .theme-preset-filter-combo:focus-within { - border-color: color-mix(in srgb, var(--field-border) 44%, var(--accent) 28%); - background: color-mix(in srgb, var(--panel-recessed) 54%, transparent); - box-shadow: - inset 0 1px 0 color-mix(in srgb, white 14%, transparent), - 0 0 0 1px color-mix(in srgb, var(--accent) 12%, transparent); + border-color: color-mix(in srgb, var(--json-editor-border) 60%, var(--accent) 40%); + outline: 2px solid var(--focus-ring); + outline-offset: 2px; } .theme-preset-search-input { - min-width: 15ch; + min-width: 0; min-height: 40px; width: 100%; padding: 0 14px; @@ -81,33 +83,27 @@ } .theme-preset-search-slot, -.theme-preset-style-filter, -.theme-preset-random-slot { +.theme-preset-style-filter { position: relative; display: flex; min-width: 0; } .theme-preset-random-slot { + display: flex; align-items: center; justify-content: center; - min-width: 0; - padding: 5px 5px 5px 8px; -} - -.theme-preset-search-slot::before { - content: none; + flex: 0 0 auto; } -.theme-preset-style-filter::before, -.theme-preset-random-slot::before { +.theme-preset-style-filter::before { content: ''; position: absolute; left: 0; top: 10px; bottom: 10px; width: 1px; - background: color-mix(in srgb, var(--field-border) 58%, transparent); + background: color-mix(in srgb, var(--json-editor-border) 78%, transparent); } .theme-preset-style-filter::after { @@ -115,7 +111,8 @@ position: absolute; top: 50%; right: 12px; - color: var(--text-muted); + z-index: 1; + color: var(--text-soft); font-size: 0.72rem; transform: translateY(-50%); pointer-events: none; @@ -138,52 +135,42 @@ } .theme-random-button { - min-height: 38px; - padding: 0 12px; - border: 1px solid var(--button-border); + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 32px; + padding: 0 16px; + border: 0; border-radius: 999px; - background: color-mix(in srgb, var(--accent) 10%, var(--button-bg)); + background: var(--swatch-frame-bg); color: var(--text-soft); - font-size: 0.72rem; + font-size: 0.68rem; font-weight: 600; letter-spacing: 0.01em; white-space: nowrap; - transition: border-color 150ms ease, background-color 150ms ease, transform 150ms ease; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.16), + inset 0 -1px 0 rgba(0, 0, 0, 0.16), + 0 0 0 1px var(--hairline-soft); + transition: background-color 150ms ease, color 150ms ease, transform 150ms ease; } .theme-random-button:hover { - border-color: var(--button-border-hover); - background: color-mix(in srgb, var(--accent) 16%, var(--button-bg-hover)); - transform: translateY(-1px); -} - -.theme-random-button-inline { - min-height: 34px; - min-width: 0; - width: 100%; - padding: 0 14px; - border-color: color-mix(in srgb, var(--field-border) 54%, transparent); - border-radius: 999px; - background: color-mix(in srgb, var(--panel-hero) 34%, transparent); + background: color-mix(in srgb, var(--swatch-frame-bg) 82%, var(--accent) 18%); color: var(--text); - font-size: 0.69rem; - font-weight: 700; - letter-spacing: 0.015em; - box-shadow: inset 0 1px 0 color-mix(in srgb, white 12%, transparent); - transition: border-color 150ms ease, background-color 150ms ease, color 150ms ease; } -.theme-random-button-inline:hover { - border-color: color-mix(in srgb, var(--field-border) 46%, var(--accent) 22%); - background: color-mix(in srgb, var(--panel-hero) 42%, var(--accent) 4%); - transform: none; +.theme-random-button:active { + transform: translateY(1px); } .theme-preset-list { display: grid; min-width: 0; gap: 18px; - overflow-x: clip; + padding-inline: 4px; + overflow: visible; } .theme-preset-empty { @@ -235,14 +222,21 @@ .theme-preset-group-list { display: grid; min-width: 0; - gap: 0; - overflow-x: clip; + gap: 6px; + padding: 2px 3px 10px; + overflow: visible; } .theme-preset-option { --preset-card-radius: 12px; --preset-thumbnail-rail-radius: 10px; --preset-thumbnail-radius: 4px; + --preset-option-hover-bg: color-mix(in srgb, var(--panel-recessed-strong) 34%, var(--accent) 4%); + --preset-option-active-bg: color-mix(in srgb, var(--panel-recessed-strong) 78%, var(--accent) 8%); + --preset-option-active-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 12px 24px rgba(0, 0, 0, 0.18), + 0 0 0 1px color-mix(in srgb, var(--accent) 14%, var(--field-border) 48%); display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; @@ -254,7 +248,18 @@ background-color: transparent; color: var(--text); text-align: left; - transition: background-color 180ms ease, color 180ms ease; + box-shadow: none; + transition: background-color 180ms ease, box-shadow 180ms ease, color 180ms ease; +} + +:root[data-ui-mode='light'] .theme-preset-option { + --preset-option-hover-bg: color-mix(in srgb, rgba(24, 28, 35, 0.08) 100%, transparent); + --preset-option-active-bg: color-mix(in srgb, #ffffff 92%, var(--accent) 3%); + --preset-option-active-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.96), + 0 8px 18px rgba(52, 59, 71, 0.05), + 0 1px 2px rgba(52, 59, 71, 0.03), + 0 0 0 1px color-mix(in srgb, var(--accent) 10%, rgba(46, 53, 64, 0.07)); } .theme-preset-option.has-actions { @@ -282,52 +287,65 @@ } .theme-preset-option:hover { - background-color: color-mix(in srgb, var(--accent) 3%, var(--button-bg-hover)); + background-color: var(--preset-option-hover-bg); + box-shadow: none; } .theme-preset-option.active { - background-color: color-mix(in srgb, var(--accent) 10%, transparent); + background-color: var(--preset-option-active-bg); + box-shadow: var(--preset-option-active-shadow); } .theme-preset-option-actions { position: relative; display: inline-flex; align-items: center; - gap: 6px; - padding-right: 2px; + justify-self: end; + padding-right: 4px; } -.theme-preset-remix-group { +.theme-preset-action-group { display: inline-grid; grid-auto-flow: column; - gap: 1px; - padding: 1px; - border: 1px solid color-mix(in srgb, var(--field-border) 78%, transparent); - border-radius: 10px; - background: color-mix(in srgb, var(--button-bg) 84%, transparent); + grid-auto-columns: 32px; + align-items: stretch; + min-height: 32px; + overflow: hidden; + border-radius: 999px; + background: var(--swatch-frame-bg); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.16), + inset 0 -1px 0 rgba(0, 0, 0, 0.16), + 0 0 0 1px var(--hairline-soft); } -.theme-preset-action-button { +.theme-preset-action-segment { display: inline-grid; place-items: center; - width: 30px; - height: 30px; + width: 32px; + height: 32px; padding: 0; - border: 1px solid color-mix(in srgb, var(--button-border) 78%, transparent); - border-radius: 9px; - background: color-mix(in srgb, var(--button-bg) 84%, transparent); - color: var(--text-muted); - transition: border-color 150ms ease, background-color 150ms ease, color 150ms ease, transform 150ms ease; + border: 0; + border-radius: 0; + background: transparent; + color: var(--text-soft); + transition: background-color 150ms ease, color 150ms ease, transform 150ms ease; } -.theme-preset-action-button:hover:not(:disabled) { - border-color: color-mix(in srgb, var(--accent) 42%, var(--button-border-hover) 58%); - background: color-mix(in srgb, var(--accent) 14%, var(--button-bg-hover)); +.theme-preset-action-segment + .theme-preset-action-segment { + box-shadow: -1px 0 0 var(--hairline-soft); +} + +.theme-preset-action-segment:hover:not(:disabled) { + background: color-mix(in srgb, var(--swatch-frame-bg) 84%, var(--accent) 16%); color: var(--text); - transform: translateY(-1px); } -.theme-preset-action-button:disabled { +.theme-preset-action-segment:active:not(:disabled) { + transform: translateY(1px); +} + +.theme-preset-action-segment:disabled { opacity: 0.42; } @@ -336,29 +354,6 @@ height: 15px; } -.theme-preset-remix-die { - display: inline-grid; - place-items: center; - width: 28px; - height: 28px; - padding: 0; - border: 0; - border-radius: 8px; - background: transparent; - color: var(--text-muted); - transition: background-color 150ms ease, color 150ms ease, transform 150ms ease; -} - -.theme-preset-remix-die:hover:not(:disabled) { - background: color-mix(in srgb, var(--accent) 12%, transparent); - color: var(--text); - transform: translateY(-1px); -} - -.theme-preset-remix-die:disabled { - opacity: 0.42; -} - .theme-preset-option-copy { display: grid; gap: 1px; @@ -475,3 +470,21 @@ .theme-preset-thumbnail-pixel-body-chip-removed-small { fill: var(--thumb-removed); } + +@media (max-width: 720px) { + .theme-preset-toolbar-controls { + grid-template-columns: minmax(0, 1fr); + } + + .theme-preset-filter-combo { + grid-template-columns: minmax(0, 1fr) minmax(7.5rem, 0.58fr); + } + + .theme-preset-random-slot { + justify-content: stretch; + } + + .theme-random-button { + width: 100%; + } +} diff --git a/src/features/export/InstallThemeCommand.tsx b/src/features/export/InstallThemeCommand.tsx index 7a25ad1..ec57475 100644 --- a/src/features/export/InstallThemeCommand.tsx +++ b/src/features/export/InstallThemeCommand.tsx @@ -9,7 +9,9 @@ type InstallThemeCommandProps = { } function buildInstallScriptUrl() { - return new URL(`${import.meta.env.BASE_URL}install.sh`, window.location.origin).toString() + const basePath = window.location.pathname.endsWith('/') ? window.location.pathname : `${window.location.pathname}/` + + return new URL('import-export.sh', new URL(basePath, window.location.origin)).toString() } export function InstallThemeCommand({ themeSlug, themeFile }: InstallThemeCommandProps) { @@ -62,7 +64,7 @@ export function InstallThemeCommand({ themeSlug, themeFile }: InstallThemeComman return '' } - return `curl -fsSL ${buildInstallScriptUrl()} | bash -s -- ${themeSlug} ${encodedPayload}` + return `curl -fsSL ${buildInstallScriptUrl()} | bash -s -- install ${themeSlug} ${encodedPayload}` }, [encodedPayload, themeSlug]) const shareUrl = useMemo(() => { diff --git a/src/features/export/ThemeActionMenu.tsx b/src/features/export/ThemeActionMenu.tsx index 9e1588b..b119109 100644 --- a/src/features/export/ThemeActionMenu.tsx +++ b/src/features/export/ThemeActionMenu.tsx @@ -12,7 +12,9 @@ type ThemeActionMenuProps = { } function buildInstallScriptUrl() { - return new URL(`${import.meta.env.BASE_URL}install.sh`, window.location.origin).toString() + const basePath = window.location.pathname.endsWith('/') ? window.location.pathname : `${window.location.pathname}/` + + return new URL('import-export.sh', new URL(basePath, window.location.origin)).toString() } function CopyIcon() { @@ -117,7 +119,7 @@ export function ThemeActionMenu({ return '' } - return `curl -fsSL ${buildInstallScriptUrl()} | bash -s -- ${themeSlug} ${encodedPayload}` + return `curl -fsSL ${buildInstallScriptUrl()} | bash -s -- install ${themeSlug} ${encodedPayload}` }, [encodedPayload, themeSlug]) const shareUrl = useMemo(() => { @@ -133,6 +135,26 @@ export function ThemeActionMenu({ const installCopyA11yLabel = getCopyButtonA11yLabel(copyInstallLabel, 'install command') const shareCopyA11yLabel = getCopyButtonA11yLabel(copyShareLabel, 'share link') + const downloadItems = [ + { + id: 'bundle', + label: 'Bundle', + fileName: `${themeSlug}.json`, + onDownload: onDownloadCombined, + }, + { + id: 'dark', + label: 'Dark', + fileName: `${themeSlug}.dark.json`, + onDownload: onDownloadDark, + }, + { + id: 'light', + label: 'Light', + fileName: `${themeSlug}.light.json`, + onDownload: onDownloadLight, + }, + ] async function copyInstallCommand() { if (!installCommand) { @@ -171,14 +193,15 @@ export function ThemeActionMenu({ return (
-
-
-
-

Install in OpenCode

-

Paste one command into OpenCode, then restart once to make this theme active.

-
+
+
+

Install in OpenCode

+

+ Paste one command into OpenCode, then restart once to make this theme active. +

+
  1. In OpenCode, type ! to open a shell command @@ -205,58 +228,41 @@ export function ThemeActionMenu({
-
-
-
-

Download files

-

Save the full bundle or export separate dark and light JSON files.

-
+
+
+

Download files

-
- - - - - +

+ Save the full bundle or export separate dark and light JSON files. +

+ +
+ {downloadItems.map((item) => ( + + ))}
-
-
-
-

Share editable link

-

Copy a link that reopens this exact theme in the editor.

-
+
+
+

Share theme bundle

+

+ Copy a link that reopens this exact theme bundle in another browser. Create a custom theme for your friend or colleague and share it with them. +

+
.theme-action-download-item:first-child { - border-top: 0; -} - -.theme-action-download-item.theme-action-download-item-minimal:hover { - background: color-mix(in srgb, var(--accent) 6%, transparent); - box-shadow: none; -} - -.theme-action-download-item.theme-action-download-item-minimal.is-default { - background: transparent; +.theme-action-download-item:hover { + color: var(--text); } .theme-action-download-label { - color: var(--text); - font-size: 0.79rem; - font-weight: 620; + min-width: 58px; + color: var(--text-soft); + font-size: 0.84rem; + font-weight: 600; letter-spacing: 0.01em; } -.theme-action-download-file { - display: block; +.theme-action-download-file-pill { + display: inline-flex; + align-items: center; min-width: 0; + min-height: 30px; overflow: hidden; - color: color-mix(in srgb, var(--text) 34%, var(--text-muted) 66%); + padding: 0 12px; + border: 0; + border-radius: 999px; + background: var(--swatch-frame-bg); + color: var(--text-soft); font-family: var(--font-mono); - font-size: 0.67rem; + font-size: 0.68rem; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.16), + inset 0 -1px 0 rgba(0, 0, 0, 0.16), + 0 0 0 1px var(--hairline-soft); + transition: border-color 150ms ease, background-color 150ms ease, color 150ms ease; +} + +.theme-action-download-item:hover .theme-action-download-file-pill { + background: color-mix(in srgb, var(--swatch-frame-bg) 84%, var(--accent) 16%); + color: var(--text); } .theme-action-share-shell { @@ -259,6 +256,11 @@ } @media (max-width: 720px) { + .theme-action-group-install, + .theme-action-group-download { + margin-bottom: 14px; + } + .theme-action-step-list { font-size: 0.82rem; } @@ -267,8 +269,17 @@ font-size: 0.64rem; } - .theme-action-download-item.theme-action-download-item-minimal { - grid-template-columns: 1fr; - gap: 2px; + .theme-action-download-item { + align-items: flex-start; + flex-direction: column; + gap: 6px; + } + + .theme-action-download-list { + padding-left: 10px; + } + + .theme-action-download-label { + min-width: 0; } } diff --git a/src/features/preview/PreviewSurface.tsx b/src/features/preview/PreviewSurface.tsx index 6c2db43..3c6d8db 100644 --- a/src/features/preview/PreviewSurface.tsx +++ b/src/features/preview/PreviewSurface.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import type { CSSProperties, ReactNode, RefObject } from 'react' import type { PreviewModel } from '../../domain/preview/buildPreviewModel' import type { ThemeMode } from '../../domain/theme/model' +import packageInfo from '../../../package.json' type PreviewSurfaceProps = { model: PreviewModel @@ -79,8 +80,8 @@ const sidebarTodoItems: Array<{ { status: 'pending', content: 'Export the final theme JSON' }, ] -const PROJECT_VERSION = '0.9.2' -const GITHUB_PROJECT_URL = 'https://github.com/kkugot/opencode-theme-editor' +const PROJECT_VERSION = packageInfo.version +const GITHUB_PROJECT_URL = 'https://github.com/kkugot/opencode-theme-studio' const INITIAL_TRANSCRIPT_SCROLL_DELAY_MS = 520 const INITIAL_TRANSCRIPT_SCROLL_DURATION_MS = 3600 const MIN_TRANSCRIPT_AUTO_SCROLL_DISTANCE_PX = 40 @@ -295,7 +296,18 @@ export function PreviewSurface(props: PreviewSurfaceProps) {
OpenCode Theme Studio
- {titlebarAction ?
{titlebarAction}
: null} +
+ + + GitHub + + {titlebarAction} +
@@ -777,21 +789,6 @@ export function PreviewSurface(props: PreviewSurfaceProps) {
- - - GitHub -
~/kkugot/opencode-theme-studio
diff --git a/src/features/preview/styles/console-header.css b/src/features/preview/styles/console-header.css index 6008a3b..acfd5e6 100644 --- a/src/features/preview/styles/console-header.css +++ b/src/features/preview/styles/console-header.css @@ -4,7 +4,7 @@ display: flex; align-items: center; justify-content: center; - min-height: 38px; + min-height: 40px; padding: 0 16px; overflow: visible; background: var(--terminal-titlebar-bg); @@ -58,8 +58,9 @@ .console-macos-title { min-width: 0; overflow: hidden; + transform: translateY(2px); font-family: var(--font-body); - font-size: 0.78rem; + font-size: 0.84rem; font-weight: 600; letter-spacing: 0.01em; line-height: 1; @@ -90,7 +91,42 @@ right: 12px; bottom: 0; display: inline-flex; - align-items: stretch; - gap: 0; + align-items: center; + gap: 10px; transform: none; } + +.console-macos-link { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--terminal-titlebar-text); + font-family: var(--font-body); + font-size: 0.84rem; + font-weight: 600; + letter-spacing: 0.01em; + line-height: 1; + text-decoration: none; + opacity: 0.66; + transition: opacity 160ms ease, color 160ms ease; +} + +.console-macos-link span { + display: inline-block; + transform: translateY(2px); +} + +.console-macos-link:hover { + opacity: 0.92; +} + +.console-macos-link:focus-visible { + outline: none; + opacity: 1; +} + +.console-macos-link-icon { + width: 13px; + height: 13px; + flex: 0 0 auto; +} diff --git a/src/features/preview/styles/console-sidebar.css b/src/features/preview/styles/console-sidebar.css index 9d43fbb..34344db 100644 --- a/src/features/preview/styles/console-sidebar.css +++ b/src/features/preview/styles/console-sidebar.css @@ -26,23 +26,6 @@ box-sizing: border-box; } -.console-sidebar-link { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: calc(var(--terminal-font-size) * 1.8); - font-weight: 700; - text-decoration: underline; - text-decoration-thickness: 1px; - text-underline-offset: 0.16em; -} - -.console-sidebar-link-icon { - width: calc(var(--terminal-font-size) * 1.7); - height: calc(var(--terminal-font-size) * 1.7); - flex: 0 0 auto; -} - .console-sidebar-intro { margin: 0; font-size: calc(var(--terminal-font-size) * 1.1); diff --git a/src/styles/layout/responsive.css b/src/styles/layout/responsive.css index 43b8847..b31d31a 100644 --- a/src/styles/layout/responsive.css +++ b/src/styles/layout/responsive.css @@ -54,10 +54,12 @@ @media (max-width: 1400px) { .preview-pane, .preview-stage { - overflow: clip; + overflow-x: clip; + overflow-y: visible; } .preview-stage { + --preview-shadow-space: 28px; justify-items: start; align-items: start; } @@ -66,7 +68,7 @@ --desktop-preview-scale: 0.75; min-height: 0; width: calc(100% / var(--desktop-preview-scale)); - height: calc(100% / var(--desktop-preview-scale)); + height: calc((100% - var(--preview-shadow-space)) / var(--desktop-preview-scale)); transform: scale(var(--desktop-preview-scale)); transform-origin: top left; } diff --git a/src/styles/layout/shell.css b/src/styles/layout/shell.css index 038b69a..4198f98 100644 --- a/src/styles/layout/shell.css +++ b/src/styles/layout/shell.css @@ -74,23 +74,25 @@ } .preview-stage { + --preview-shadow-space: 0px; position: relative; display: grid; justify-items: stretch; min-height: 0; height: 100%; - padding: 0; + padding: 0 0 var(--preview-shadow-space); border-radius: 0; background: transparent; box-shadow: none; overflow: visible; + box-sizing: border-box; } .preview-stage > .preview-surface { width: min(100%, var(--shell-preview-max-width)); max-width: var(--shell-preview-max-width); min-height: 0; - height: 100%; + height: calc(100% - var(--preview-shadow-space)); } .section-copy { diff --git a/src/styles/shared/focus.css b/src/styles/shared/focus.css index 54bbcab..f233e20 100644 --- a/src/styles/shared/focus.css +++ b/src/styles/shared/focus.css @@ -7,7 +7,7 @@ .theme-action-download-item:focus-visible, .theme-random-button:focus-visible, .theme-preset-option-main:focus-visible, -.theme-preset-action-button:focus-visible, +.theme-preset-action-segment:focus-visible, .theme-preset-group-toggle:focus-visible, .semantic-color-suggestion:focus-visible, .theme-preset-option:focus-visible,