4-digit hex

#rgba is a valid VS Code color, but the hex pattern accepted only 3/6/8 digits — so a model emitting #fff8 produced a malformed line that counted toward the abort budget. One alternative added to the regex.

The 4-digit alternative, with the reason it was missing.

src/domain/color.ts · 68 lines
src/domain/color.ts68 lines · TypeScript
⋯ 20 lines hidden (lines 1–20)
1/**
2 * `ColorValue` — the only representation of a color the rest of the system accepts.
3 * A value exists in exactly one of three shapes: a normalized hex string, a CSS
4 * keyword, or the `Remove` sentinel used during iteration to clear an override.
5 * Arbitrary strings cannot enter; `parseColor` is the sole constructor.
6 */
7 
8import { type Brand, matchTag, Result, type ResultType } from '../fp';
9import { malformed, type ValidationError } from './errors';
10 
11/** A lowercase `#rgb` / `#rgba` / `#rrggbb` / `#rrggbbaa` string, proven by construction. */
12export type HexColor = Brand<string, 'HexColor'>;
13 
14export type NamedColor = 'transparent' | 'inherit' | 'initial' | 'unset';
15 
16export type ColorValue =
17 | { readonly _tag: 'Hex'; readonly value: HexColor }
18 | { readonly _tag: 'Named'; readonly value: NamedColor }
19 | { readonly _tag: 'Remove' };
20 
21// 3/4/6/8 digits — all the hex forms VS Code's color customizations accept
22// (#rgb, #rgba, #rrggbb, #rrggbbaa). Omitting #rgba meant a valid color counted
23// as a malformed line toward the abort budget.
24const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
⋯ 44 lines hidden (lines 25–68)
25const NAMED: ReadonlySet<string> = new Set<NamedColor>([
26 'transparent',
27 'inherit',
28 'initial',
29 'unset',
30]);
31const REMOVE_SENTINEL = 'REMOVE';
32 
33export const remove: ColorValue = { _tag: 'Remove' };
34export const hex = (value: HexColor): ColorValue => ({ _tag: 'Hex', value });
35export const named = (value: NamedColor): ColorValue => ({ _tag: 'Named', value });
36 
37export const parseColor = (raw: string): ResultType<ValidationError, ColorValue> => {
38 const trimmed = raw.trim();
39 
40 if (trimmed.toUpperCase() === REMOVE_SENTINEL) {
41 return Result.ok(remove);
42 }
43 if (HEX_RE.test(trimmed)) {
44 return Result.ok(hex(trimmed.toLowerCase() as HexColor));
45 }
46 const lower = trimmed.toLowerCase();
47 if (NAMED.has(lower)) {
48 return Result.ok(named(lower as NamedColor));
49 }
50 return Result.err(
51 malformed('color', 'a hex code (#rgb/#rgba/#rrggbb/#rrggbbaa), a CSS keyword, or REMOVE', trimmed),
52 );
53};
54 
55/**
56 * How a color resolves against VS Code config: either set a string value, or delete
57 * the key entirely (the `Remove` case, which lets the base theme show through).
58 */
59export type ColorApplication =
60 | { readonly _tag: 'Set'; readonly value: string }
61 | { readonly _tag: 'Delete' };
62 
63export const toApplication = (color: ColorValue): ColorApplication =>
64 matchTag(color, {
65 Hex: ({ value }) => ({ _tag: 'Set', value }),
66 Named: ({ value }) => ({ _tag: 'Set', value }),
67 Remove: () => ({ _tag: 'Delete' }),
68 });

A write-scope setting

There were no user-facing settings. Add vibeThemer.applyTo (global | workspace, default global). writePreference now takes the preferred target and orders the first-success-wins list accordingly — workspace only matters with a folder open, so it falls through to global otherwise. ConfigStore reads the setting via applyTo().

The contributes.configuration block.

package.json · 113 lines
package.json113 lines · JSON
⋯ 71 lines hidden (lines 1–71)
1{
2 "name": "vibe-themer",
3 "displayName": "Vibe Themer",
4 "description": "🎨 AI-powered VS Code theme generator. Describe your vibe and watch a complete theme stream in live, generated by OpenAI or Anthropic models.",
5 "version": "2.1.0",
6 "publisher": "mseeks",
7 "license": "MIT",
8 "icon": "icon.png",
9 "galleryBanner": {
10 "color": "#1e1e2e",
11 "theme": "dark"
12 },
13 "repository": {
14 "type": "git",
15 "url": "https://github.com/mseeks/vibe-themer"
16 },
17 "homepage": "https://github.com/mseeks/vibe-themer#readme",
18 "bugs": {
19 "url": "https://github.com/mseeks/vibe-themer/issues"
20 },
21 "sponsor": {
22 "url": "https://github.com/sponsors/mseeks"
23 },
24 "engines": {
25 "vscode": "^1.74.0"
26 },
27 "activationEvents": [
28 "onCommand:vibeThemer.changeTheme",
29 "onCommand:vibeThemer.clearApiKey",
30 "onCommand:vibeThemer.resetTheme",
31 "onCommand:vibeThemer.selectModel",
32 "onCommand:vibeThemer.resetModel"
33 ],
34 "categories": [
35 "Themes",
36 "Other"
37 ],
38 "keywords": [
39 "theme",
40 "color",
41 "ai",
42 "openai",
43 "anthropic",
44 "claude",
45 "gpt",
46 "customization"
47 ],
48 "main": "./out/extension.js",
49 "contributes": {
50 "commands": [
51 {
52 "command": "vibeThemer.changeTheme",
53 "title": "Vibe Themer: Change Theme"
54 },
55 {
56 "command": "vibeThemer.clearApiKey",
57 "title": "Vibe Themer: Clear API Keys"
58 },
59 {
60 "command": "vibeThemer.resetTheme",
61 "title": "Vibe Themer: Reset Theme Customizations"
62 },
63 {
64 "command": "vibeThemer.selectModel",
65 "title": "Vibe Themer: Select Model"
66 },
67 {
68 "command": "vibeThemer.resetModel",
69 "title": "Vibe Themer: Reset Model Selection"
70 }
71 ],
72 "configuration": {
73 "title": "Vibe Themer",
74 "properties": {
75 "vibeThemer.applyTo": {
76 "type": "string",
77 "enum": [
78 "global",
79 "workspace"
80 ],
81 "enumDescriptions": [
82 "User (global) settings — the theme follows you across all windows.",
83 "The current workspace's settings — falls back to global if no folder is open."
84 ],
85 "default": "global",
86 "description": "Where Vibe Themer writes generated theme customizations."
87 }
88 }
⋯ 25 lines hidden (lines 89–113)
89 }
90 },
91 "scripts": {
92 "vscode:prepublish": "npm run package",
93 "package": "npm run check-types && npm run lint && node ./esbuild.js --production",
94 "compile": "npm run check-types && npm run lint && node ./esbuild.js",
95 "check-types": "tsc --noEmit",
96 "watch": "npm run check-types && node ./esbuild.js --watch",
97 "lint": "eslint src test --ext ts",
98 "test": "node ./esbuild.test.js && node out/test.cjs"
99 },
100 "devDependencies": {
101 "@types/node": "^20.17.58",
102 "@types/vscode": "^1.74.0",
103 "@typescript-eslint/eslint-plugin": "^7.7.1",
104 "@typescript-eslint/parser": "^7.7.1",
105 "esbuild": "^0.25.12",
106 "eslint": "^8.57.0",
107 "typescript": "^5.4.5"
108 },
109 "dependencies": {
110 "@anthropic-ai/sdk": "^0.102.0",
111 "openai": "^6.42.0"
112 }

Pure ordering: preference first, workspace gated on an open folder.

src/domain/scope.ts · 40 lines
src/domain/scope.ts40 lines · TypeScript
⋯ 20 lines hidden (lines 1–20)
1/**
2 * Where theme customizations live. `ConfigurationScope` describes *where existing*
3 * customizations were found; `writePreference` describes where new ones go.
4 *
5 * The write preference is an ordered, non-empty list: try the first target, fall
6 * back to the next only if it fails (first-success-wins). The user's `applyTo`
7 * setting picks which target to try first; `workspace` is only meaningful with a
8 * folder open, so without one we always fall through to `global`.
9 */
10 
11import { matchTag, type NonEmptyArray } from '../fp';
12 
13export type WriteTarget = 'global' | 'workspace';
14 
15export type ConfigurationScope =
16 | { readonly _tag: 'Global' }
17 | { readonly _tag: 'Workspace' }
18 | { readonly _tag: 'Both' }
19 | { readonly _tag: 'None' };
20 
21export const writePreference = (
22 preferred: WriteTarget,
23 hasWorkspaceFolders: boolean,
24): NonEmptyArray<WriteTarget> => {
25 if (preferred === 'workspace') {
26 return hasWorkspaceFolders ? ['workspace', 'global'] : ['global'];
27 }
28 return hasWorkspaceFolders ? ['global', 'workspace'] : ['global'];
29};
30 
⋯ 10 lines hidden (lines 31–40)
31export const describeTarget = (target: WriteTarget): string =>
32 target === 'global' ? 'global settings' : 'workspace settings';
33 
34export const describeScope = (scope: ConfigurationScope): string =>
35 matchTag(scope, {
36 Global: () => 'global settings',
37 Workspace: () => 'workspace settings',
38 Both: () => 'global and workspace settings',
39 None: () => 'no settings',
40 });

The adapter reads vibeThemer.applyTo.

src/adapters/vscode/config.ts · 110 lines
src/adapters/vscode/config.ts110 lines · TypeScript
⋯ 77 lines hidden (lines 1–77)
1import * as vscode from 'vscode';
2import { type AsyncResultType, err, type NonEmptyArray, ok } from '../../fp';
3import { applyColor, applyTokenRule, type TextMateRule } from '../../domain/customizations';
4import { type WriteTarget } from '../../domain/scope';
5import {
6 type ColorMap,
7 type CurrentTheme,
8 type ScopedTheme,
9 type ThemeSetting,
10 type TokenCustomizations,
11} from '../../domain/theme';
12import { type ConfigError, type ConfigStore } from '../../ports';
13 
14const COLOR_KEY = 'workbench.colorCustomizations';
15const TOKEN_KEY = 'editor.tokenColorCustomizations';
16 
17interface TokenCustomizationsShape {
18 readonly textMateRules?: ReadonlyArray<TextMateRule>;
19 readonly [key: string]: unknown;
21 
22// VS Code config is user-editable JSON, so reads are validated, never trusted blindly.
23const isPlainObject = (value: unknown): value is Record<string, unknown> =>
24 typeof value === 'object' && value !== null && !Array.isArray(value);
25 
26const asColorMap = (value: unknown): ColorMap => (isPlainObject(value) ? (value as ColorMap) : {});
27 
28const asTokenShape = (value: unknown): TokenCustomizationsShape =>
29 isPlainObject(value) ? (value as TokenCustomizationsShape) : {};
30 
31const targetOf = (target: WriteTarget): vscode.ConfigurationTarget =>
32 target === 'global'
33 ? vscode.ConfigurationTarget.Global
34 : vscode.ConfigurationTarget.Workspace;
35 
36const scopedTheme = (colors: unknown, tokens: unknown): ScopedTheme => ({
37 colors: asColorMap(colors),
38 tokens: isPlainObject(tokens) ? (tokens as TokenCustomizations) : {},
39});
40 
41const applyToTarget = async (
42 setting: ThemeSetting,
43 target: WriteTarget,
44): AsyncResultType<ConfigError, void> => {
45 try {
46 const config = vscode.workspace.getConfiguration();
47 const vsTarget = targetOf(target);
48 
49 if (setting._tag === 'SelectorSetting') {
50 const existing = asColorMap(config.get<unknown>(COLOR_KEY));
51 const next = applyColor(existing, setting.selector, setting.color);
52 await config.update(COLOR_KEY, next, vsTarget);
53 } else {
54 const existing = asTokenShape(config.get<unknown>(TOKEN_KEY));
55 const rules = Array.isArray(existing.textMateRules) ? existing.textMateRules : [];
56 const nextRules = applyTokenRule(rules, setting.scope, setting.color, setting.fontStyle);
57 await config.update(TOKEN_KEY, { ...existing, textMateRules: nextRules }, vsTarget);
58 }
59 return ok(undefined);
60 } catch {
61 return err({ _tag: 'WriteFailed', target });
62 }
63};
64 
65export const createConfigStore = (): ConfigStore => ({
66 readCurrentTheme: (): CurrentTheme => {
67 const config = vscode.workspace.getConfiguration();
68 const colors = config.inspect<unknown>(COLOR_KEY);
69 const tokens = config.inspect<unknown>(TOKEN_KEY);
70 return {
71 global: scopedTheme(colors?.globalValue, tokens?.globalValue),
72 workspace: scopedTheme(colors?.workspaceValue, tokens?.workspaceValue),
73 };
74 },
75 
76 hasWorkspaceFolders: (): boolean => (vscode.workspace.workspaceFolders?.length ?? 0) > 0,
77 
78 applyTo: (): WriteTarget =>
79 vscode.workspace.getConfiguration('vibeThemer').get<string>('applyTo') === 'workspace'
80 ? 'workspace'
81 : 'global',
⋯ 29 lines hidden (lines 82–110)
82 
83 applySetting: async (
84 setting: ThemeSetting,
85 preference: NonEmptyArray<WriteTarget>,
86 ): AsyncResultType<ConfigError, WriteTarget> => {
87 for (const target of preference) {
88 const result = await applyToTarget(setting, target);
89 if (result._tag === 'Ok') {
90 return ok(target);
91 }
92 }
93 return err({ _tag: 'AllTargetsFailed' });
94 },
95 
96 reset: async (): AsyncResultType<ConfigError, void> => {
97 const config = vscode.workspace.getConfiguration();
98 // `allSettled` never rejects, so inspect the outcomes: surface failure only when
99 // every target failed (a workspace-target failure with no folder open is normal).
100 const outcomes = await Promise.allSettled([
101 config.update(COLOR_KEY, undefined, vscode.ConfigurationTarget.Workspace),
102 config.update(TOKEN_KEY, undefined, vscode.ConfigurationTarget.Workspace),
103 config.update(COLOR_KEY, undefined, vscode.ConfigurationTarget.Global),
104 config.update(TOKEN_KEY, undefined, vscode.ConfigurationTarget.Global),
105 ]);
106 return outcomes.every((o) => o.status === 'rejected')
107 ? err({ _tag: 'AllTargetsFailed' })
108 : ok(undefined);
109 },
110});

Tests

The 4-digit hex case and all four preference orderings.

writePreference orderings; the hex case is in the parseColor suite above.

test/domain.test.ts · 139 lines
test/domain.test.ts139 lines · TypeScript
⋯ 60 lines hidden (lines 1–60)
1import { describe, it } from 'node:test';
2import assert from 'node:assert/strict';
3import { parseColor, toApplication } from '../src/domain/color';
4import { parseVibe } from '../src/domain/vibe';
5import { parseSelector } from '../src/domain/selector';
6import { parseTokenScope } from '../src/domain/tokenScope';
7import { parseFontStyle } from '../src/domain/fontStyle';
8import { writePreference } from '../src/domain/scope';
9import { parseApiKey, renderApiKeyError } from '../src/domain/apiKey';
10import { CATALOG, DEFAULT_MODEL, makeModel, modelText, parseModelId, sameModel } from '../src/domain/model';
11import { expose } from '../src/fp';
12 
13describe('parseColor', () => {
14 it('accepts 3/4/6/8-digit hex (lowercased), keywords, and REMOVE', () => {
15 assert.deepEqual(parseColor('#ABC'), { _tag: 'Ok', value: { _tag: 'Hex', value: '#abc' } });
16 assert.deepEqual(parseColor('#FFF8'), { _tag: 'Ok', value: { _tag: 'Hex', value: '#fff8' } });
17 assert.deepEqual(parseColor(' #1e1e1e '), {
18 _tag: 'Ok',
19 value: { _tag: 'Hex', value: '#1e1e1e' },
20 });
21 assert.deepEqual(parseColor('UNSET'), { _tag: 'Ok', value: { _tag: 'Named', value: 'unset' } });
22 assert.deepEqual(parseColor('remove'), { _tag: 'Ok', value: { _tag: 'Remove' } });
23 });
24 
25 it('rejects malformed colors', () => {
26 assert.equal(parseColor('blue')._tag, 'Err');
27 assert.equal(parseColor('#12')._tag, 'Err');
28 assert.equal(parseColor('rgb(0,0,0)')._tag, 'Err');
29 });
30 
31 it('maps to an application instruction', () => {
32 const hex = parseColor('#fff');
33 if (hex._tag === 'Ok') {
34 assert.deepEqual(toApplication(hex.value), { _tag: 'Set', value: '#fff' });
35 }
36 const rm = parseColor('REMOVE');
37 if (rm._tag === 'Ok') {
38 assert.deepEqual(toApplication(rm.value), { _tag: 'Delete' });
39 }
40 });
41});
42 
43describe('parseVibe', () => {
44 it('trims and requires at least three characters', () => {
45 assert.equal(parseVibe(' cozy autumn ')._tag, 'Ok');
46 assert.equal(parseVibe(' ')._tag, 'Err');
47 assert.equal(parseVibe('ab')._tag, 'Err');
48 });
49});
50 
51describe('parseSelector / parseTokenScope', () => {
52 it('accepts dotted identifiers and rejects spaces or empties', () => {
53 assert.equal(parseSelector('editorIndentGuide.activeBackground6')._tag, 'Ok');
54 assert.equal(parseSelector('has space')._tag, 'Err');
55 assert.equal(parseSelector('')._tag, 'Err');
56 assert.equal(parseTokenScope('comment.line.double-slash')._tag, 'Ok');
57 assert.equal(parseTokenScope('with=equals')._tag, 'Err');
58 });
59});
60 
61describe('writePreference', () => {
62 it('orders targets by the user preference, with workspace gated on an open folder', () => {
63 assert.deepEqual(writePreference('global', true), ['global', 'workspace']);
64 assert.deepEqual(writePreference('global', false), ['global']);
65 assert.deepEqual(writePreference('workspace', true), ['workspace', 'global']);
66 // No folder open → workspace isn't a real target; fall through to global.
67 assert.deepEqual(writePreference('workspace', false), ['global']);
68 });
69});
⋯ 70 lines hidden (lines 70–139)
70 
71describe('parseFontStyle', () => {
72 it('accepts none, single, and combined flags; rejects unknown', () => {
73 assert.equal(parseFontStyle('none')._tag, 'Ok');
74 assert.equal(parseFontStyle('Bold')._tag, 'Ok');
75 assert.equal(parseFontStyle('bold underline')._tag, 'Ok');
76 assert.equal(parseFontStyle('wobbly')._tag, 'Err');
77 });
78});
79 
80describe('parseApiKey', () => {
81 it('validates the provider prefix and length, wrapping in Redacted', () => {
82 const good = parseApiKey('openai', `sk-${'x'.repeat(40)}`);
83 assert.equal(good._tag, 'Ok');
84 if (good._tag === 'Ok') {
85 assert.equal(String(good.value), '<redacted>');
86 assert.equal(JSON.stringify({ key: good.value }), '{"key":"<redacted>"}');
87 assert.equal(expose(good.value).startsWith('sk-'), true);
88 }
89 assert.equal(parseApiKey('anthropic', `sk-ant-${'x'.repeat(40)}`)._tag, 'Ok');
90 });
91 
92 it('rejects empty, wrong-prefix, and too-short keys without echoing the key', () => {
93 assert.deepEqual(parseApiKey('openai', ''), { _tag: 'Err', error: { _tag: 'KeyEmpty' } });
94 assert.deepEqual(parseApiKey('openai', 'pk-123456789012345678901234'), {
95 _tag: 'Err',
96 error: { _tag: 'KeyBadPrefix', provider: 'openai' },
97 });
98 assert.deepEqual(parseApiKey('openai', 'sk-short'), {
99 _tag: 'Err',
100 error: { _tag: 'KeyTooShort' },
101 });
102 assert.equal(
103 renderApiKeyError({ _tag: 'KeyBadPrefix', provider: 'openai' }).includes('pk-'),
104 false,
105 );
106 });
107 
108 it('routes keys per provider — an Anthropic key is invalid for OpenAI and vice versa', () => {
109 assert.deepEqual(parseApiKey('openai', `sk-ant-${'x'.repeat(40)}`), {
110 _tag: 'Err',
111 error: { _tag: 'KeyBadPrefix', provider: 'openai' },
112 });
113 assert.deepEqual(parseApiKey('anthropic', `sk-${'x'.repeat(40)}`), {
114 _tag: 'Err',
115 error: { _tag: 'KeyBadPrefix', provider: 'anthropic' },
116 });
117 });
118});
119 
120describe('model & catalog', () => {
121 it('defaults to gpt-5.5 on OpenAI', () => {
122 assert.equal(DEFAULT_MODEL.provider, 'openai');
123 assert.equal(modelText(DEFAULT_MODEL.id), 'gpt-5.5');
124 });
125 
126 it('curates a small catalog that includes the default', () => {
127 const ids = CATALOG.map((s) => modelText(s.model.id));
128 assert.ok(ids.includes('gpt-5.5'));
129 assert.ok(ids.includes('claude-sonnet-4-6'));
130 assert.ok(CATALOG.some((s) => sameModel(s.model, DEFAULT_MODEL)));
131 });
132 
133 it('parses a custom id and compares models by provider + id', () => {
134 assert.equal(parseModelId('gpt-4o')._tag, 'Some');
135 assert.equal(parseModelId(' ')._tag, 'None');
136 assert.equal(sameModel(makeModel('openai', 'x'), makeModel('openai', 'x')), true);
137 assert.equal(sameModel(makeModel('openai', 'x'), makeModel('anthropic', 'x')), false);
138 });
139});