Problem
Feature configuration shapes are described in four redundant places, and button config is copy-pasted across 8+ features:
- Global
configuration type (src/types/index.ts:228-353) — a 125-line TypeScript union, one key per feature
schemaInput (each feature's index.metadata.ts) — a Zod schema that must exactly mirror configuration[featureId]
defaults (each feature's index.metadata.ts) — default values validated against schemaInput at registration time
settings[].id strings like "loopButton.button.enabled" — plain strings with no type-level path validation
Add a new field or rename one, and you touch 3-4 places per feature x 57 features = ~200 edit points. The worst offender is the button config { enabled: boolean, fullscreenPlacement: FullscreenPlacement, placement: ButtonPlacement }, which is manually copy-pasted in 8+ metadata files (loopButton, screenshotButton, flipVideoButtons, maximizePlayerButton, monoToStereoButton, openTranscriptButton, hideEndScreenCardsButton, etc.).
The metadata files (index.metadata.ts) must remain separate from feature implementation files (index.ts) because they are loaded eagerly by import.meta.glob in contexts where browser APIs are unavailable (popup, options page).
Proposed Interface
The field() primitive
Packages a Zod type + its default value into one object — the single source of truth for both schemaInput and defaults:
interface Field<out T, out D extends T = T> {
readonly _field: true;
readonly schema: z.ZodMiniType<T>;
readonly default: D;
}
function field<T, D extends T>(schema: z.ZodMiniType<T>, defaultValue: D): Field<T, D>
Type and value derivation
Two runtime extractors walk the config tree to produce defaults and schemaInput:
type InferType<T extends ConfigShape> = {
[K in keyof T]: T[K] extends Field<infer V> ? V
: T[K] extends ConfigShape ? InferType<T[K]>
: never;
};
function extractDefaults(shape): InferType<typeof shape> // collects .default from each Field
function extractSchemaInput(shape): Record<string, any> // collects .schema from each Field
Reusable button config composable
export const buttonField = {
enabled: field(z.boolean(), false),
fullscreenPlacement: field(z.enum(fullscreenPlacements), "same" as const),
placement: field(z.enum(buttonPlacements), "feature_menu" as const),
} satisfies ConfigShape;
Any feature with a button does { ...buttonField, placement: field(...) } instead of repeating the 6 lines.
Updated createFeatureMetadata
Accepts config instead of separate defaults + schemaInput. Derives both internally:
export function createFeatureMetadata<K extends FeatureKeys>(
input: Omit<FeatureMetadata<K>, "defaults" | "schemaInput"> & {
config: ConfigShape;
},
): FeatureMetadata<K> {
const defaults = extractDefaults(input.config);
const schemaInput = extractSchemaInput(input.config);
return { ...input, defaults, schemaInput } as FeatureMetadata<K>;
}
Usage: simple feature (hideScrollBar)
export const metadata = createFeatureMetadata({
id: "hideScrollBar",
config: { enabled: field(z.boolean(), false) },
settings: [{ component: "checkbox", id: "hideScrollBar.enabled", label, title }],
});
// defaults = { enabled: false }, schemaInput = { enabled: z.boolean() } -- derived
Usage: button feature (loopButton)
export const metadata = createFeatureMetadata({
id: "loopButton",
dependencies: { includePages: ["watch"] },
config: { button: { ...buttonField, placement: field(z.enum(buttonPlacements), "feature_menu") } },
settings: [{ component: "checkbox", id: "loopButton.button.enabled", label, title }],
});
Usage: complex feature (volumeBoost)
export const metadata = createFeatureMetadata({
id: "volumeBoost",
dependencies: { includePages: ["watch", "live", "shorts"] },
config: {
enabled: field(z.boolean(), false),
mode: field(z.enum(volumeBoostModes), "global"),
amount: field(z.number(), 5),
button: { ...buttonField, placement: field(z.enum(buttonPlacements), "player_controls_left") },
},
sectionTitle: (t) => t((tr) => tr.settings.sections.volumeBoost.title),
settings: [
{
type: "group", section: "volumeBoost",
children: [
{ component: "checkbox", id: "volumeBoost.enabled", label, title },
{ component: "select", id: "volumeBoost.mode", disabledWhen: [...], ... },
{ component: "number", id: "volumeBoost.amount", min: 1, max: 100, step: 1, ... },
],
},
],
});
What complexity it hides internally
- Runtime:
extractDefaults() and extractSchemaInput() recursively walk the ConfigShape tree, collecting .default and .schema from each Field leaf into matching tree structures
- Type-level:
InferType<T> recursively maps Field<V> -> V to produce the TypeScript type that lifecycle handlers receive (onEnable(config: InferType<...>))
- Registration: The glob-based auto-discovery in
featureMetadataRegistry.ts:322 works unchanged — it still reads schemaInput + defaults from the metadata object
What stays the same
| Aspect |
Status |
index.metadata.ts separation |
Preserved — pure file, no browser deps |
settings array UI metadata |
Still hand-written (labels, components, conditions are irreducible) |
Setting id strings |
Still raw strings (no regression) |
import.meta.glob discovery |
Works unchanged |
validateDefaults() |
Receives same format defaults + schemaInput |
Global configuration type |
Stays hand-written in src/types/index.ts |
| Feature lifecycle handlers |
Still receive configuration[K] — unchanged |
| Existing metadata files |
Backward compatible (or one-time migration) |
Dependency Strategy
In-process. This is pure build-time / init-time computation. The field() primitive, extractors, and createFeatureMetadata update all run in the same process as the existing registration code. No external services, no I/O, no network dependencies.
Testing Strategy
There are currently zero test files in the repository. After this refactor:
New boundary tests to write
extractDefaults — given a ConfigShape tree, assert the output matches expected defaults object
extractSchemaInput — given a ConfigShape tree, assert the output is a valid Zod shape
createFeatureMetadata integration — given a config + settings, assert the resulting metadata passes validateDefaults() and validateSettingsIds() from the existing registry
- Type-level equality — assert
InferType<typeof config> is structurally identical to the corresponding slice of configuration (catches type drift at compile time)
Old tests to delete
N/A — no tests exist. No regression risk.
Test environment needs
- Zod v4-mini (already a dependency)
- Vitest or Node test runner (not yet in the project — would need to be added)
Implementation Recommendations
What the module should own
- A single
defineConfig.ts file in src/features/_registry/ containing:
- The
Field type and field() factory
- The
ConfigShape type and InferType utility type
extractDefaults() and extractSchemaInput() runtime extractors
- The shared
buttonField composable
What it should hide
- The recursive walking logic that produces
defaults and schemaInput from a ConfigShape
- The type-level mapping from
Field<V> to plain TypeScript V
- The fact that there were ever two separate declarations for schema and defaults
What it should expose
field() — the only primitive a feature developer needs
buttonField — the reusable button shape (or similar for other common patterns)
createFeatureMetadata() — updated to accept config key (backward compatible or as a new overload)
Migration path
- Create
defineConfig.ts with the field() primitive, extractors, and buttonField
- Update
createFeatureMetadata to accept the config key
- Migrate one feature at a time (start with
hideScrollBar -> loopButton -> ...)
- Both old (
defaults + schemaInput) and new (config) can coexist during migration
- After all 57 features are migrated, remove the old parameters from
createFeatureMetadata
Problem
Feature configuration shapes are described in four redundant places, and button config is copy-pasted across 8+ features:
configurationtype (src/types/index.ts:228-353) — a 125-line TypeScript union, one key per featureschemaInput(each feature'sindex.metadata.ts) — a Zod schema that must exactly mirrorconfiguration[featureId]defaults(each feature'sindex.metadata.ts) — default values validated againstschemaInputat registration timesettings[].idstrings like"loopButton.button.enabled"— plain strings with no type-level path validationAdd a new field or rename one, and you touch 3-4 places per feature x 57 features = ~200 edit points. The worst offender is the button config
{ enabled: boolean, fullscreenPlacement: FullscreenPlacement, placement: ButtonPlacement }, which is manually copy-pasted in 8+ metadata files (loopButton, screenshotButton, flipVideoButtons, maximizePlayerButton, monoToStereoButton, openTranscriptButton, hideEndScreenCardsButton, etc.).The metadata files (
index.metadata.ts) must remain separate from feature implementation files (index.ts) because they are loaded eagerly byimport.meta.globin contexts where browser APIs are unavailable (popup, options page).Proposed Interface
The
field()primitivePackages a Zod type + its default value into one object — the single source of truth for both
schemaInputanddefaults:Type and value derivation
Two runtime extractors walk the
configtree to producedefaultsandschemaInput:Reusable button config composable
Any feature with a button does
{ ...buttonField, placement: field(...) }instead of repeating the 6 lines.Updated
createFeatureMetadataAccepts
configinstead of separatedefaults+schemaInput. Derives both internally:Usage: simple feature (
hideScrollBar)Usage: button feature (
loopButton)Usage: complex feature (
volumeBoost)What complexity it hides internally
extractDefaults()andextractSchemaInput()recursively walk theConfigShapetree, collecting.defaultand.schemafrom eachFieldleaf into matching tree structuresInferType<T>recursively mapsField<V> -> Vto produce the TypeScript type that lifecycle handlers receive (onEnable(config: InferType<...>))featureMetadataRegistry.ts:322works unchanged — it still readsschemaInput+defaultsfrom the metadata objectWhat stays the same
index.metadata.tsseparationsettingsarray UI metadataidstringsimport.meta.globdiscoveryvalidateDefaults()defaults+schemaInputconfigurationtypesrc/types/index.tsconfiguration[K]— unchangedDependency Strategy
In-process. This is pure build-time / init-time computation. The
field()primitive, extractors, andcreateFeatureMetadataupdate all run in the same process as the existing registration code. No external services, no I/O, no network dependencies.Testing Strategy
There are currently zero test files in the repository. After this refactor:
New boundary tests to write
extractDefaults— given aConfigShapetree, assert the output matches expected defaults objectextractSchemaInput— given aConfigShapetree, assert the output is a valid Zod shapecreateFeatureMetadataintegration — given a config + settings, assert the resulting metadata passesvalidateDefaults()andvalidateSettingsIds()from the existing registryInferType<typeof config>is structurally identical to the corresponding slice ofconfiguration(catches type drift at compile time)Old tests to delete
N/A — no tests exist. No regression risk.
Test environment needs
Implementation Recommendations
What the module should own
defineConfig.tsfile insrc/features/_registry/containing:Fieldtype andfield()factoryConfigShapetype andInferTypeutility typeextractDefaults()andextractSchemaInput()runtime extractorsbuttonFieldcomposableWhat it should hide
defaultsandschemaInputfrom aConfigShapeField<V>to plain TypeScriptVWhat it should expose
field()— the only primitive a feature developer needsbuttonField— the reusable button shape (or similar for other common patterns)createFeatureMetadata()— updated to acceptconfigkey (backward compatible or as a new overload)Migration path
defineConfig.tswith thefield()primitive, extractors, andbuttonFieldcreateFeatureMetadatato accept theconfigkeyhideScrollBar->loopButton-> ...)defaults+schemaInput) and new (config) can coexist during migrationcreateFeatureMetadata