diff --git a/src/app/shared/ui/color-picker/color-picker.component.html b/src/app/shared/ui/color-picker/color-picker.component.html
new file mode 100644
index 0000000..def8a71
--- /dev/null
+++ b/src/app/shared/ui/color-picker/color-picker.component.html
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/src/app/shared/ui/color-picker/color-picker.component.scss b/src/app/shared/ui/color-picker/color-picker.component.scss
new file mode 100644
index 0000000..be73ac7
--- /dev/null
+++ b/src/app/shared/ui/color-picker/color-picker.component.scss
@@ -0,0 +1,59 @@
+:host {
+ display: block;
+}
+
+.app-color-picker {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.app-color-picker-swatch {
+ flex: 0 0 44px;
+ height: 40px;
+ padding: 4px;
+ border: 1px solid var(--border-color, #e4e4e7);
+ border-radius: var(--radius-md, 6px);
+ background: var(--bg-primary, #fff);
+ cursor: pointer;
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+}
+
+.app-color-picker-text {
+ flex: 1;
+ height: 40px;
+ padding: 0 var(--space-md, 1rem);
+ border: 1px solid var(--border-color, #e4e4e7);
+ border-radius: var(--radius-md, 6px);
+ background: var(--bg-primary, #fff);
+ color: var(--text-primary, #1f322d);
+ font-family: inherit;
+ font-size: 0.9375rem;
+ transition: border-color var(--transition-fast, 120ms ease),
+ box-shadow var(--transition-fast, 120ms ease);
+
+ &:focus-visible {
+ outline: none;
+ border-color: var(--primary-color, #2f6e5d);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #2f6e5d) 25%, transparent);
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+}
+
+.app-color-picker-text--invalid {
+ border-color: var(--error-color, #c0392b);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .app-color-picker-text {
+ transition: none;
+ }
+}
diff --git a/src/app/shared/ui/color-picker/color-picker.component.ts b/src/app/shared/ui/color-picker/color-picker.component.ts
new file mode 100644
index 0000000..ae9920c
--- /dev/null
+++ b/src/app/shared/ui/color-picker/color-picker.component.ts
@@ -0,0 +1,74 @@
+import { ChangeDetectionStrategy, Component, forwardRef, inject, input } from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { FormFieldContext } from '../form-field/form-field.component';
+
+const HEX_COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
+
+@Component({
+ selector: 'app-color-picker',
+ standalone: true,
+ templateUrl: './color-picker.component.html',
+ styleUrl: './color-picker.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => ColorPickerComponent),
+ multi: true
+ }
+ ]
+})
+export class ColorPickerComponent implements ControlValueAccessor {
+ protected readonly formField = inject(FormFieldContext, { optional: true });
+
+ readonly disabled = input(false);
+ readonly ariaLabel = input(null);
+
+ protected value = '#000000';
+ protected textValue = '#000000';
+ protected isDisabled = false;
+ protected invalid = false;
+
+ private onChange: (value: string) => void = () => {};
+ private onTouched: () => void = () => {};
+
+ writeValue(value: string): void {
+ this.value = HEX_COLOR.test(value ?? '') ? value : '#000000';
+ this.textValue = value ?? '';
+ this.invalid = !HEX_COLOR.test(this.textValue);
+ }
+
+ registerOnChange(fn: (value: string) => void): void {
+ this.onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this.onTouched = fn;
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ this.isDisabled = isDisabled;
+ }
+
+ protected handleSwatchChange(event: Event): void {
+ const target = event.target as HTMLInputElement;
+ this.value = target.value;
+ this.textValue = target.value;
+ this.invalid = false;
+ this.onChange(this.value);
+ }
+
+ protected handleTextChange(event: Event): void {
+ const target = event.target as HTMLInputElement;
+ this.textValue = target.value;
+ this.invalid = !HEX_COLOR.test(this.textValue);
+ if (!this.invalid) {
+ this.value = this.textValue;
+ this.onChange(this.value);
+ }
+ }
+
+ protected handleBlur(): void {
+ this.onTouched();
+ }
+}
diff --git a/src/app/shared/ui/key-value-editor/key-value-editor.component.html b/src/app/shared/ui/key-value-editor/key-value-editor.component.html
new file mode 100644
index 0000000..aae721a
--- /dev/null
+++ b/src/app/shared/ui/key-value-editor/key-value-editor.component.html
@@ -0,0 +1,11 @@
+
+ @for (row of rows(); track $index) {
+
+
+
+
+
{{ removeLabel() }}
+
+ }
+
{{ addLabel() }}
+
diff --git a/src/app/shared/ui/key-value-editor/key-value-editor.component.scss b/src/app/shared/ui/key-value-editor/key-value-editor.component.scss
new file mode 100644
index 0000000..7f5bcc6
--- /dev/null
+++ b/src/app/shared/ui/key-value-editor/key-value-editor.component.scss
@@ -0,0 +1,24 @@
+:host {
+ display: block;
+}
+
+.app-key-value-editor {
+ display: grid;
+ gap: 10px;
+}
+
+.app-key-value-editor-row {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ border: 1px solid var(--border-color, #d3dad9);
+ border-radius: 12px;
+ padding: 12px;
+ background: var(--bg-secondary, #fbfcfc);
+}
+
+.app-key-value-editor-row-content {
+ flex: 1;
+ display: grid;
+ gap: 8px;
+}
diff --git a/src/app/shared/ui/key-value-editor/key-value-editor.component.ts b/src/app/shared/ui/key-value-editor/key-value-editor.component.ts
new file mode 100644
index 0000000..aef0e26
--- /dev/null
+++ b/src/app/shared/ui/key-value-editor/key-value-editor.component.ts
@@ -0,0 +1,35 @@
+import { ChangeDetectionStrategy, Component, ContentChild, TemplateRef, input, output } from '@angular/core';
+import { NgTemplateOutlet } from '@angular/common';
+import { ButtonComponent } from '../button/button.component';
+
+export interface KeyValueRowContext {
+ $implicit: T;
+ index: number;
+}
+
+@Component({
+ selector: 'app-key-value-editor',
+ standalone: true,
+ imports: [NgTemplateOutlet, ButtonComponent],
+ templateUrl: './key-value-editor.component.html',
+ styleUrl: './key-value-editor.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class KeyValueEditorComponent {
+ readonly rows = input.required();
+ readonly addLabel = input('Add');
+ readonly removeLabel = input('Remove');
+ readonly createRow = input.required<() => T>();
+
+ readonly rowsChange = output();
+
+ @ContentChild(TemplateRef) rowTemplate: TemplateRef> | null = null;
+
+ protected addRow(): void {
+ this.rowsChange.emit([...this.rows(), this.createRow()()]);
+ }
+
+ protected removeRow(index: number): void {
+ this.rowsChange.emit(this.rows().filter((_, i) => i !== index));
+ }
+}
diff --git a/src/app/shared/ui/locale-tabs/locale-tabs.component.html b/src/app/shared/ui/locale-tabs/locale-tabs.component.html
new file mode 100644
index 0000000..190ba77
--- /dev/null
+++ b/src/app/shared/ui/locale-tabs/locale-tabs.component.html
@@ -0,0 +1,14 @@
+
+ @for (locale of locales(); track locale) {
+
+ }
+
diff --git a/src/app/shared/ui/locale-tabs/locale-tabs.component.scss b/src/app/shared/ui/locale-tabs/locale-tabs.component.scss
new file mode 100644
index 0000000..2fd9289
--- /dev/null
+++ b/src/app/shared/ui/locale-tabs/locale-tabs.component.scss
@@ -0,0 +1,43 @@
+:host {
+ display: block;
+}
+
+.app-locale-tabs {
+ display: flex;
+ gap: 4px;
+ border-bottom: 1px solid var(--border-color, #d3dad9);
+}
+
+.app-locale-tab {
+ padding: 8px 16px;
+ border: none;
+ border-bottom: 2px solid transparent;
+ background: transparent;
+ color: var(--text-secondary, #5f6e6a);
+ font-weight: 600;
+ font-size: 0.875rem;
+ text-transform: uppercase;
+ cursor: pointer;
+ transition: color var(--transition-fast, 120ms ease),
+ border-color var(--transition-fast, 120ms ease);
+
+ &:hover {
+ color: var(--text-primary, #1e3c38);
+ }
+
+ &:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #2f6e5d) 25%, transparent);
+ }
+}
+
+.app-locale-tab--active {
+ color: var(--primary-color, #2f6e5d);
+ border-bottom-color: var(--primary-color, #2f6e5d);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .app-locale-tab {
+ transition: none;
+ }
+}
diff --git a/src/app/shared/ui/locale-tabs/locale-tabs.component.ts b/src/app/shared/ui/locale-tabs/locale-tabs.component.ts
new file mode 100644
index 0000000..3fa7592
--- /dev/null
+++ b/src/app/shared/ui/locale-tabs/locale-tabs.component.ts
@@ -0,0 +1,27 @@
+import { ChangeDetectionStrategy, Component, input, output, signal } from '@angular/core';
+
+@Component({
+ selector: 'app-locale-tabs',
+ standalone: true,
+ templateUrl: './locale-tabs.component.html',
+ styleUrl: './locale-tabs.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class LocaleTabsComponent {
+ readonly locales = input.required();
+ readonly defaultLocale = input(null);
+ readonly activeLocale = output();
+
+ private readonly selected = signal(null);
+
+ protected currentLocale(): string {
+ const explicit = this.selected();
+ if (explicit && this.locales().includes(explicit)) return explicit;
+ return this.defaultLocale() ?? this.locales()[0] ?? '';
+ }
+
+ protected select(locale: string): void {
+ this.selected.set(locale);
+ this.activeLocale.emit(locale);
+ }
+}
diff --git a/src/app/shared/ui/section-card/section-card.component.html b/src/app/shared/ui/section-card/section-card.component.html
new file mode 100644
index 0000000..fd0b086
--- /dev/null
+++ b/src/app/shared/ui/section-card/section-card.component.html
@@ -0,0 +1,14 @@
+
+ @if (title(); as title) {
+
+ }
+
+
+
+
diff --git a/src/app/shared/ui/section-card/section-card.component.scss b/src/app/shared/ui/section-card/section-card.component.scss
new file mode 100644
index 0000000..ff5cd93
--- /dev/null
+++ b/src/app/shared/ui/section-card/section-card.component.scss
@@ -0,0 +1,34 @@
+:host {
+ display: block;
+}
+
+.app-section-card {
+ background: var(--bg-primary, #fff);
+ border: 1px solid var(--border-color, #d3dad9);
+ border-radius: 16px;
+ padding: 18px;
+ display: grid;
+ gap: 14px;
+}
+
+.app-section-card-header {
+ display: grid;
+ gap: 4px;
+}
+
+.app-section-card-header h2 {
+ margin: 0;
+ font-size: 1.125rem;
+ color: var(--text-primary, #1e3c38);
+}
+
+.app-section-card-description {
+ margin: 0;
+ font-size: 0.875rem;
+ color: var(--text-secondary, #5f6e6a);
+}
+
+.app-section-card-body {
+ display: grid;
+ gap: 14px;
+}
diff --git a/src/app/shared/ui/section-card/section-card.component.ts b/src/app/shared/ui/section-card/section-card.component.ts
new file mode 100644
index 0000000..311f676
--- /dev/null
+++ b/src/app/shared/ui/section-card/section-card.component.ts
@@ -0,0 +1,13 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+
+@Component({
+ selector: 'app-section-card',
+ standalone: true,
+ templateUrl: './section-card.component.html',
+ styleUrl: './section-card.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class SectionCardComponent {
+ readonly title = input(null);
+ readonly description = input(null);
+}
diff --git a/src/app/shared/ui/select/select.component.html b/src/app/shared/ui/select/select.component.html
new file mode 100644
index 0000000..c87e1b0
--- /dev/null
+++ b/src/app/shared/ui/select/select.component.html
@@ -0,0 +1,20 @@
+
+@if (activeDescription(); as description) {
+ {{ description }}
+}
diff --git a/src/app/shared/ui/select/select.component.scss b/src/app/shared/ui/select/select.component.scss
new file mode 100644
index 0000000..451d9c2
--- /dev/null
+++ b/src/app/shared/ui/select/select.component.scss
@@ -0,0 +1,68 @@
+:host {
+ display: inline-block;
+}
+
+:host.app-select-host--full-width {
+ display: block;
+ width: 100%;
+}
+
+.app-select {
+ width: 100%;
+ border: 1px solid var(--border-color, #e4e4e7);
+ border-radius: var(--radius-md, 6px);
+ background: var(--bg-primary, #fff);
+ color: var(--text-primary, #1f322d);
+ font-family: inherit;
+ cursor: pointer;
+ transition: border-color var(--transition-fast, 120ms ease),
+ box-shadow var(--transition-fast, 120ms ease);
+
+ &:hover:not(:disabled) {
+ border-color: var(--primary-color, #2f6e5d);
+ }
+
+ &:focus-visible {
+ outline: none;
+ border-color: var(--primary-color, #2f6e5d);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #2f6e5d) 25%, transparent);
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+}
+
+.app-select--sm {
+ height: 32px;
+ padding: 0 var(--space-sm, 0.5rem);
+ font-size: 0.8125rem;
+}
+
+.app-select--md {
+ height: 40px;
+ padding: 0 var(--space-md, 1rem);
+ font-size: 0.9375rem;
+}
+
+.app-select--lg {
+ height: 48px;
+ padding: 0 var(--space-lg, 1.5rem);
+ font-size: 1rem;
+}
+
+.app-select-description {
+ display: block;
+ font-weight: 400;
+ font-size: 12px;
+ line-height: 1.4;
+ color: var(--text-secondary, #6b7280);
+ margin-top: 4px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .app-select {
+ transition: none;
+ }
+}
diff --git a/src/app/shared/ui/select/select.component.ts b/src/app/shared/ui/select/select.component.ts
new file mode 100644
index 0000000..672bff0
--- /dev/null
+++ b/src/app/shared/ui/select/select.component.ts
@@ -0,0 +1,74 @@
+import { ChangeDetectionStrategy, Component, forwardRef, inject, input } from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { FormFieldContext } from '../form-field/form-field.component';
+
+export type SelectSize = 'sm' | 'md' | 'lg';
+
+export interface SelectOption {
+ value: string;
+ label: string;
+ description?: string;
+}
+
+@Component({
+ selector: 'app-select',
+ standalone: true,
+ templateUrl: './select.component.html',
+ styleUrl: './select.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => SelectComponent),
+ multi: true
+ }
+ ],
+ host: {
+ '[class.app-select-host--full-width]': 'fullWidth()'
+ }
+})
+export class SelectComponent implements ControlValueAccessor {
+ protected readonly formField = inject(FormFieldContext, { optional: true });
+
+ readonly options = input.required();
+ readonly size = input('md');
+ readonly disabled = input(false);
+ readonly fullWidth = input(true);
+ readonly ariaLabel = input(null);
+
+ protected value = '';
+ protected isDisabled = false;
+
+ private onChange: (value: string) => void = () => {};
+ private onTouched: () => void = () => {};
+
+ writeValue(value: string): void {
+ this.value = value ?? '';
+ }
+
+ registerOnChange(fn: (value: string) => void): void {
+ this.onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this.onTouched = fn;
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ this.isDisabled = isDisabled;
+ }
+
+ protected handleChange(event: Event): void {
+ const target = event.target as HTMLSelectElement;
+ this.value = target.value;
+ this.onChange(this.value);
+ }
+
+ protected handleBlur(): void {
+ this.onTouched();
+ }
+
+ protected activeDescription(): string | null {
+ return this.options().find(option => option.value === this.value)?.description ?? null;
+ }
+}
diff --git a/src/app/shared/ui/toggle/toggle.component.html b/src/app/shared/ui/toggle/toggle.component.html
new file mode 100644
index 0000000..d9c67a4
--- /dev/null
+++ b/src/app/shared/ui/toggle/toggle.component.html
@@ -0,0 +1,15 @@
+
diff --git a/src/app/shared/ui/toggle/toggle.component.scss b/src/app/shared/ui/toggle/toggle.component.scss
new file mode 100644
index 0000000..77042b3
--- /dev/null
+++ b/src/app/shared/ui/toggle/toggle.component.scss
@@ -0,0 +1,72 @@
+:host {
+ display: inline-block;
+}
+
+.app-toggle {
+ position: relative;
+ width: 42px;
+ height: 24px;
+ border-radius: 999px;
+ border: 1px solid var(--border-color, #d3dad9);
+ background: var(--bg-secondary, #e4e4e7);
+ cursor: pointer;
+ padding: 0;
+ transition: background-color var(--transition-fast, 120ms ease),
+ border-color var(--transition-fast, 120ms ease);
+
+ &:hover:not(:disabled) {
+ border-color: var(--primary-color, #2f6e5d);
+ }
+
+ &:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #2f6e5d) 25%, transparent);
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+}
+
+.app-toggle--sm {
+ width: 34px;
+ height: 20px;
+}
+
+.app-toggle-thumb {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
+ transition: transform var(--transition-fast, 120ms ease);
+}
+
+.app-toggle--sm .app-toggle-thumb {
+ width: 14px;
+ height: 14px;
+}
+
+.app-toggle--checked {
+ background: var(--primary-color, #2f6e5d);
+ border-color: var(--primary-color, #2f6e5d);
+}
+
+.app-toggle--checked .app-toggle-thumb {
+ transform: translateX(18px);
+}
+
+.app-toggle--sm.app-toggle--checked .app-toggle-thumb {
+ transform: translateX(14px);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .app-toggle,
+ .app-toggle-thumb {
+ transition: none;
+ }
+}
diff --git a/src/app/shared/ui/toggle/toggle.component.ts b/src/app/shared/ui/toggle/toggle.component.ts
new file mode 100644
index 0000000..a626852
--- /dev/null
+++ b/src/app/shared/ui/toggle/toggle.component.ts
@@ -0,0 +1,60 @@
+import { ChangeDetectionStrategy, Component, forwardRef, input } from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+
+export type ToggleSize = 'sm' | 'md';
+
+@Component({
+ selector: 'app-toggle',
+ standalone: true,
+ templateUrl: './toggle.component.html',
+ styleUrl: './toggle.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => ToggleComponent),
+ multi: true
+ }
+ ]
+})
+export class ToggleComponent implements ControlValueAccessor {
+ readonly size = input('md');
+ readonly disabled = input(false);
+ readonly ariaLabel = input(null);
+
+ protected checked = false;
+ protected isDisabled = false;
+
+ private onChange: (value: boolean) => void = () => {};
+ private onTouched: () => void = () => {};
+
+ writeValue(value: boolean): void {
+ this.checked = !!value;
+ }
+
+ registerOnChange(fn: (value: boolean) => void): void {
+ this.onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this.onTouched = fn;
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ this.isDisabled = isDisabled;
+ }
+
+ protected handleClick(): void {
+ if (this.isDisabled || this.disabled()) return;
+ this.checked = !this.checked;
+ this.onChange(this.checked);
+ this.onTouched();
+ }
+
+ protected handleKeydown(event: KeyboardEvent): void {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ this.handleClick();
+ }
+ }
+}