feat: flatten components/kit -> components folder

This commit is contained in:
2026-09-10 20:08:15 +05:00
parent f7623ab3bb
commit f8a1da6ff7
65 changed files with 40 additions and 40 deletions
@@ -0,0 +1,32 @@
<script lang="ts">
interface Props {
label: string;
checked: boolean;
onchange?: (checked: boolean) => void;
}
let { label, checked = $bindable(), onchange }: Props = $props();
function handle(e: Event) {
checked = (e.target as HTMLInputElement).checked;
onchange?.(checked);
}
</script>
<label class="checkbox-field">
<input type="checkbox" {checked} onchange={handle} />
<span class="checkbox-label">{label}</span>
</label>
<style>
.checkbox-field {
display: inline-flex;
align-items: center;
gap: var(--space-m);
font-size: var(--font-size-m);
color: var(--color-text);
cursor: pointer;
}
.checkbox-field input {
accent-color: var(--color-main);
}
</style>
@@ -0,0 +1,80 @@
<script lang="ts">
import { ChevronDown } from "@lucide/svelte";
interface Props {
label: string;
value: string;
dark?: boolean;
chevron?: boolean;
oninput?: (value: string) => void;
}
let {
label,
value = $bindable(),
dark = false,
chevron = false,
oninput,
}: Props = $props();
function handle(e: Event) {
value = (e.target as HTMLInputElement).value;
oninput?.(value);
}
</script>
<label class="control-block">
<span class="control-title">{label}</span>
<div class="color-field">
<span class="swatch" class:dark style="background:{value}"></span>
<input
type="text"
aria-label={label}
{value}
oninput={handle}
spellcheck="false"
/>
{#if chevron}<ChevronDown class="icon" size={14} />{/if}
</div>
</label>
<style>
.control-block :global(.icon) {
color: var(--color-text);
}
.control-title {
display: flex;
justify-content: space-between;
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
color: var(--color-text-muted);
margin-bottom: var(--space-m);
}
.color-field {
display: flex;
align-items: center;
gap: var(--space-m);
padding-left: var(--space-m);
padding-right: var(--space-m);
height: var(--space-xxxl);
border: var(--size-border) solid var(--color-border);
}
.swatch {
width: var(--space-l);
height: var(--space-l);
border: var(--size-border) solid var(--color-border);
flex: none;
}
.color-field input {
min-width: 0;
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
background: 0 0;
border: 0;
outline: 0;
flex: 1;
}
.color-field :global(svg) {
color: var(--color-text-muted);
flex: none;
}
</style>
@@ -0,0 +1,97 @@
<script lang="ts">
import type {
Dimension,
DimensionSpec,
FieldSpec,
} from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: Dimension) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as DimensionSpec);
const current = $derived.by(() => {
const v = value as Partial<Dimension> | undefined;
return {
width:
typeof v?.width === "number" && Number.isFinite(v.width)
? v.width
: sp.width,
height:
typeof v?.height === "number" && Number.isFinite(v.height)
? v.height
: sp.height,
};
});
function setAxis(axis: "width" | "height", n: number) {
onchange?.({ ...current, [axis]: n });
}
</script>
<div class="control dimension-control">
<span class="dimension-label">{label}</span>
<div class="dimension-field">
<label class="dimension-axis">
<span>Width</span>
<input
type="number"
min={sp.min}
max={sp.max}
value={current.width}
oninput={(e) =>
setAxis("width", Number((e.target as HTMLInputElement).value))}
/>
</label>
<label class="dimension-axis">
<span>Height</span>
<input
type="number"
min={sp.min}
max={sp.max}
value={current.height}
oninput={(e) =>
setAxis("height", Number((e.target as HTMLInputElement).value))}
/>
</label>
</div>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.dimension-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.dimension-field {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m);
}
.dimension-axis > span {
display: block;
margin-bottom: var(--space-m);
}
.dimension-axis input {
width: 100%;
padding: var(--space-m) var(--space-l);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
}
</style>
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from "svelte";
import MonoLabel from "../MonoLabel.svelte";
interface Props {
label: string;
children: Snippet;
hint?: string;
}
let { label, children, hint }: Props = $props();
</script>
<div class="field">
<MonoLabel>{label}</MonoLabel>
<div class="field-control">{@render children()}</div>
{#if hint}<span class="field-hint">{hint}</span>{/if}
</div>
<style>
.field {
display: flex;
flex-direction: column;
gap: var(--space-s);
}
.field-control {
display: flex;
align-items: center;
gap: var(--space-m);
}
.field-hint {
font-family: var(--font-mono);
font-size: var(--font-size-s);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,42 @@
<script lang="ts">
import Field from "./Field.svelte";
interface Option {
value: string;
label: string;
}
interface Props {
label: string;
value: string;
options: Option[];
onchange?: (value: string) => void;
}
let { label, value = $bindable(), options, onchange }: Props = $props();
function handle(e: Event) {
value = (e.target as HTMLSelectElement).value;
onchange?.(value);
}
</script>
<Field {label}>
<select class="select-field" {value} onchange={handle}>
{#each options as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</Field>
<style>
.select-field {
width: 100%;
font-family: var(--font-mono);
font-size: var(--font-size-s);
color: var(--color-text);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
padding: var(--space-s) var(--space-m);
}
</style>
@@ -0,0 +1,51 @@
<script lang="ts">
interface Props {
label: string;
value: number;
min?: number;
max?: number;
step?: number;
suffix?: string;
oninput?: (value: number) => void;
}
let {
label,
value = $bindable(),
min = 0,
max = 100,
step = 1,
suffix = "",
oninput,
}: Props = $props();
function handle(e: Event) {
value = Number((e.target as HTMLInputElement).value);
oninput?.(value);
}
</script>
<div class="control-block">
<label>{label} <output>{value}{suffix}</output></label>
<input type="range" {min} {max} {step} {value} oninput={handle} />
</div>
<style>
label {
display: flex;
justify-content: space-between;
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
color: var(--color-text-muted);
margin-bottom: var(--space-m);
}
output {
font: var(--font-size-s) var(--font-mono);
color: var(--color-text);
}
input[type="range"] {
color: var(--color-text);
line-height: inherit;
width: 100%;
accent-color: var(--color-main);
}
</style>
@@ -0,0 +1,50 @@
<script lang="ts">
interface Props {
value: string;
type?: string;
placeholder?: string;
readonly?: boolean;
label?: string;
oninput?: (value: string) => void;
}
let {
value = $bindable(),
type = "text",
placeholder = "",
readonly = false,
label,
oninput,
}: Props = $props();
function handle(e: Event) {
value = (e.target as HTMLInputElement).value;
oninput?.(value);
}
</script>
<input
class="text-field"
{type}
{placeholder}
{readonly}
{value}
aria-label={label}
oninput={handle}
/>
<style>
.text-field {
width: 100%;
font-family: var(--font-mono);
font-size: var(--font-size-s);
color: var(--color-text);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
padding: var(--space-s) var(--space-m);
}
.text-field:focus {
outline: none;
border-color: var(--color-main);
}
</style>
@@ -0,0 +1,107 @@
<script lang="ts">
interface Preset {
value: number;
label: string;
}
interface Props {
label?: string;
value: number;
min?: number;
max?: number;
step?: number;
presets?: Preset[];
onchange: (angle: number) => void;
}
let {
label = "Angle",
value,
min = 0,
max = 360,
step = 1,
presets = [
{ value: 0, label: "0°" },
{ value: 90, label: "90°" },
{ value: 180, label: "180°" },
{ value: 270, label: "270°" },
],
onchange,
}: Props = $props();
</script>
<div class="angle-control">
{#if label}
<span class="angle-label">{label}</span>
{/if}
<div class="angle-row">
<input
type="range"
{min}
{max}
{step}
{value}
oninput={(e) => onchange(Number((e.target as HTMLInputElement).value))}
/>
<output>{value}°</output>
</div>
<div class="angle-presets">
{#each presets as preset (preset.value)}
<button
class:active={preset.value === value}
onclick={() => onchange(preset.value)}>{preset.label}</button
>
{/each}
</div>
</div>
<style>
.angle-control {
display: grid;
gap: var(--space-m);
}
.angle-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.angle-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--space-m);
}
.angle-row input[type="range"] {
width: 100%;
accent-color: var(--color-main);
}
.angle-row output {
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.angle-presets {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-m);
}
.angle-presets button {
padding: var(--space-m) 0;
color: var(--color-text-muted);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
cursor: pointer;
}
.angle-presets button:hover {
color: var(--color-text);
border-color: var(--color-main);
}
.angle-presets button.active {
color: var(--color-main);
border-color: var(--color-main);
}
</style>
@@ -0,0 +1,47 @@
<script lang="ts">
import type { CheckboxSpec, FieldSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: boolean) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as CheckboxSpec);
const current = $derived(
typeof value === "boolean" ? value : Boolean(sp.default),
);
</script>
<label class="control checkbox-control">
<span>{label}</span>
<input
type="checkbox"
class="checkbox-field"
checked={current}
onchange={(e) => onchange?.((e.target as HTMLInputElement).checked)}
/>
</label>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.checkbox-control {
display: flex;
justify-content: space-between;
align-items: center;
}
.checkbox-field {
width: var(--space-xl);
height: var(--space-xl);
accent-color: var(--color-main);
}
</style>
@@ -0,0 +1,59 @@
<script lang="ts">
import type { ColorSpec, FieldSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: string) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as ColorSpec);
const current = $derived(typeof value === "string" ? value : sp.default);
</script>
<label class="control color-control">
<span>{label}</span>
<span class="color-row">
<span class="swatch" style="background:{current}"></span>
<input
type="color"
value={current}
oninput={(e) => onchange?.((e.target as HTMLInputElement).value)}
/>
</span>
</label>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.color-control > span:first-child {
justify-content: flex-start;
}
.color-row {
display: flex;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
input[type="color"] {
width: 100%;
height: var(--space-xxl);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: var(--color-background);
cursor: pointer;
}
</style>
@@ -0,0 +1,97 @@
<script lang="ts">
import type {
ColorPair,
ColorPairSpec,
FieldSpec,
} from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: ColorPair) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as ColorPairSpec);
const current = $derived.by(() => {
const v = value as Partial<ColorPair> | undefined;
return {
from: typeof v?.from === "string" ? v.from : sp.from,
to: typeof v?.to === "string" ? v.to : sp.to,
};
});
function setColor(axis: "from" | "to", hex: string) {
onchange?.({ ...current, [axis]: hex });
}
</script>
<div class="control color-pair-control">
<span class="pair-label">{label}</span>
<div class="pair-field">
<label class="pair-axis">
<span class="swatch" style="background:{current.from}"></span>
<input
type="color"
value={current.from}
oninput={(e) => setColor("from", (e.target as HTMLInputElement).value)}
/>
<span class="axis-label">From</span>
</label>
<label class="pair-axis">
<span class="swatch" style="background:{current.to}"></span>
<input
type="color"
value={current.to}
oninput={(e) => setColor("to", (e.target as HTMLInputElement).value)}
/>
<span class="axis-label">To</span>
</label>
</div>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.pair-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.pair-field {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m);
}
.pair-axis {
display: grid;
grid-template-columns: var(--space-xxl) 1fr;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.pair-axis input[type="color"] {
width: 100%;
height: var(--space-xxl);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: var(--color-background);
cursor: pointer;
}
.axis-label {
grid-column: 1 / -1;
}
</style>
@@ -0,0 +1,156 @@
<script lang="ts">
import type {
ColorList,
ColorListSpec,
FieldSpec,
} from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: ColorList) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as ColorListSpec);
const current = $derived.by((): ColorList => {
if (Array.isArray(value)) {
const valid = value.filter(
(c): c is string => typeof c === "string" && /^#[0-9a-f]{6}$/i.test(c),
);
if (valid.length > 0) return valid;
}
return [...sp.default];
});
function setColor(index: number, hex: string) {
const next = [...current];
next[index] = hex;
onchange?.(next);
}
function addColor() {
onchange?.([...current, "#000000"]);
}
function removeColor(index: number) {
if (current.length <= 1) return;
onchange?.(current.filter((_, i) => i !== index));
}
</script>
<div class="control colors-control">
<span class="ctrl-label">{label}</span>
<div class="chips">
{#each current as hex, i (i)}
<div class="chip">
<label class="swatch-wrap">
<span class="swatch" style="background:{hex}"></span>
<input
type="color"
value={hex}
oninput={(e) => setColor(i, (e.target as HTMLInputElement).value)}
/>
</label>
<span class="hex">{hex}</span>
<button
type="button"
class="remove"
onclick={() => removeColor(i)}
disabled={current.length <= 1}
aria-label="Remove color"
>
×
</button>
</div>
{/each}
</div>
<button type="button" class="add" onclick={addColor}>+ Add color</button>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.ctrl-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-m);
}
.chip {
display: grid;
grid-template-columns: var(--space-xxl) minmax(0, auto) auto;
align-items: center;
gap: var(--space-m);
padding: var(--space-s) var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
}
.swatch-wrap {
display: grid;
position: relative;
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.swatch-wrap input[type="color"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.hex {
color: var(--color-text-muted);
font-size: var(--font-size-s);
}
.remove {
display: flex;
align-items: center;
justify-content: center;
width: var(--space-xl);
height: var(--space-xl);
border: none;
border-radius: var(--radius-s);
background: none;
color: var(--color-text-muted);
font-size: var(--font-size-s);
cursor: pointer;
}
.remove:hover:not(:disabled) {
color: var(--color-text);
background: var(--color-background-muted);
}
.remove:disabled {
opacity: 0.4;
cursor: default;
}
.add {
justify-self: start;
padding: var(--space-s) var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: none;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.add:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,162 @@
<script lang="ts">
import type { TextFont } from "$lib/core/domText";
import type {
FieldSpec,
FontStyle,
FontStyleSpec,
} from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: FontStyle) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as FontStyleSpec);
const current = $derived.by(() => {
const v = value as Partial<FontStyle> | undefined;
return {
font:
v?.font === "sans" || v?.font === "serif" || v?.font === "mono"
? v.font
: sp.font,
size:
typeof v?.size === "number" && Number.isFinite(v.size)
? v.size
: sp.size,
bold: typeof v?.bold === "boolean" ? v.bold : sp.bold,
color: typeof v?.color === "string" ? v.color : sp.color,
};
});
function set<K extends keyof FontStyle>(key: K, val: FontStyle[K]) {
onchange?.({ ...current, [key]: val });
}
const FONTS: { value: TextFont; label: string }[] = [
{ value: "sans", label: "Sans" },
{ value: "serif", label: "Serif" },
{ value: "mono", label: "Mono" },
];
</script>
<div class="control font-style-control">
<span class="fs-label">{label}</span>
<div class="fs-grid">
<label class="fs-cell">
<span class="fs-sub">Font</span>
<select
class="fs-select"
value={current.font}
oninput={(e) =>
set("font", (e.target as HTMLSelectElement).value as TextFont)}
>
{#each FONTS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</label>
<label class="fs-cell">
<span class="fs-sub">Size</span>
<input
class="fs-input"
type="number"
min={sp.min}
max={sp.max}
value={current.size}
oninput={(e) =>
set("size", Number((e.target as HTMLInputElement).value))}
/>
</label>
<label class="fs-cell">
<span class="fs-sub">Color</span>
<span class="fs-color">
<span class="swatch" style="background:{current.color}"></span>
<input
class="fs-color-input"
type="color"
value={current.color}
oninput={(e) => set("color", (e.target as HTMLInputElement).value)}
/>
</span>
</label>
<label class="fs-cell fs-bold">
<span class="fs-sub">Bold</span>
<input
type="checkbox"
checked={current.bold}
onchange={(e) => set("bold", (e.target as HTMLInputElement).checked)}
/>
</label>
</div>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.fs-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.fs-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m);
}
.fs-cell {
display: grid;
gap: var(--space-m);
}
.fs-sub {
font-size: var(--font-size-s);
}
.fs-select,
.fs-input,
.fs-color-input {
width: 100%;
padding: var(--space-m) var(--space-l);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
}
.fs-color {
display: grid;
grid-template-columns: var(--space-xxl) 1fr;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.fs-color-input {
height: var(--space-xxl);
padding: 0;
cursor: pointer;
}
.fs-bold {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
}
.fs-bold input[type="checkbox"] {
width: var(--space-xl);
height: var(--space-xl);
accent-color: var(--color-main);
}
</style>
@@ -0,0 +1,96 @@
<script lang="ts">
import AngleControl from "./AngleControl.svelte";
import type { FieldSpec, Gradient, GradientSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: Gradient) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as GradientSpec);
const current = $derived.by(() => {
const v = value as Partial<Gradient> | undefined;
return {
from: typeof v?.from === "string" ? v.from : sp.from,
to: typeof v?.to === "string" ? v.to : sp.to,
angle:
typeof v?.angle === "number" && Number.isFinite(v.angle)
? v.angle
: sp.angle,
};
});
function set<K extends keyof Gradient>(key: K, val: Gradient[K]) {
onchange?.({ ...current, [key]: val });
}
</script>
<div class="control gradient-control">
<span class="gradient-label">{label}</span>
<div class="gradient-colors">
<label class="color-field">
<span>From</span>
<span class="swatch" style="background:{current.from}"></span>
<input
type="color"
value={current.from}
oninput={(e) => set("from", (e.target as HTMLInputElement).value)}
/>
</label>
<label class="color-field">
<span>To</span>
<span class="swatch" style="background:{current.to}"></span>
<input
type="color"
value={current.to}
oninput={(e) => set("to", (e.target as HTMLInputElement).value)}
/>
</label>
</div>
<AngleControl value={current.angle} onchange={(a) => set("angle", a)} />
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.gradient-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.gradient-colors {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m) var(--space-l);
}
.color-field {
display: grid;
grid-template-columns: auto var(--space-xxl) 1fr;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.color-field input[type="color"] {
width: 100%;
height: var(--space-xxl);
padding: 0;
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
}
</style>
@@ -0,0 +1,87 @@
<script lang="ts">
import type { FieldSpec, Offset, OffsetSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: Offset) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as OffsetSpec);
const current = $derived.by(() => {
const v = value as Partial<Offset> | undefined;
return {
x: typeof v?.x === "number" && Number.isFinite(v.x) ? v.x : sp.x,
y: typeof v?.y === "number" && Number.isFinite(v.y) ? v.y : sp.y,
};
});
function setAxis(axis: "x" | "y", n: number) {
onchange?.({ ...current, [axis]: n });
}
</script>
<div class="control offset-control">
<span class="offset-label">{label}</span>
<div class="offset-field">
<label class="offset-axis">
<span>X</span>
<input
type="number"
min={sp.min}
max={sp.max}
value={current.x}
oninput={(e) =>
setAxis("x", Number((e.target as HTMLInputElement).value))}
/>
</label>
<label class="offset-axis">
<span>Y</span>
<input
type="number"
min={sp.min}
max={sp.max}
value={current.y}
oninput={(e) =>
setAxis("y", Number((e.target as HTMLInputElement).value))}
/>
</label>
</div>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.offset-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.offset-field {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m);
}
.offset-axis > span {
display: block;
margin-bottom: var(--space-m);
}
.offset-axis input {
width: 100%;
padding: var(--space-m) var(--space-l);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
}
</style>
@@ -0,0 +1,139 @@
<script lang="ts">
import type { FieldSpec, Plate, PlateSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: Plate) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as PlateSpec);
const current = $derived.by(() => {
const v = value as Partial<Plate> | undefined;
return {
enabled: typeof v?.enabled === "boolean" ? v.enabled : sp.enabled,
color: typeof v?.color === "string" ? v.color : sp.color,
opacity:
typeof v?.opacity === "number" && Number.isFinite(v.opacity)
? v.opacity
: sp.opacity,
};
});
function set<K extends keyof Plate>(key: K, val: Plate[K]) {
onchange?.({ ...current, [key]: val });
}
</script>
<div class="control plate-control">
<span class="plate-label">{label}</span>
<div class="plate-row {current.enabled ? '' : 'off'}">
<label class="plate-enabled">
<span>Backing plate</span>
<input
type="checkbox"
checked={current.enabled}
onchange={(e) => set("enabled", (e.target as HTMLInputElement).checked)}
/>
</label>
<label class="plate-color">
<span class="swatch" style="background:{current.color}"></span>
<input
type="color"
value={current.color}
disabled={!current.enabled}
oninput={(e) => set("color", (e.target as HTMLInputElement).value)}
/>
</label>
<label class="plate-opacity">
<span>
Opacity
<output>{current.opacity}%</output>
</span>
<input
type="range"
min="0"
max="100"
step="5"
value={current.opacity}
disabled={!current.enabled}
oninput={(e) =>
set("opacity", Number((e.target as HTMLInputElement).value))}
/>
</label>
</div>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.plate-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.plate-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m) var(--space-l);
}
.plate-row.off {
opacity: 0.55;
}
.plate-enabled {
display: flex;
justify-content: space-between;
align-items: center;
grid-column: 1 / -1;
}
.plate-enabled input[type="checkbox"] {
width: var(--space-xl);
height: var(--space-xl);
accent-color: var(--color-main);
}
.plate-color {
display: grid;
grid-template-columns: var(--space-xxl) 1fr;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.plate-color input[type="color"] {
width: 100%;
height: var(--space-xxl);
padding: 0;
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
}
.plate-opacity {
display: grid;
gap: var(--space-m);
}
.plate-opacity > span {
display: flex;
justify-content: space-between;
}
.plate-opacity output {
color: var(--color-text);
}
.plate-opacity input[type="range"] {
width: 100%;
accent-color: var(--color-main);
color: var(--color-text);
}
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
import type { Position9 } from "$lib/core/textdraw";
import {
POSITION9_VALUES,
type FieldSpec,
type Position9Spec,
} from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: Position9) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as Position9Spec);
const current = $derived(
typeof value === "string" &&
(POSITION9_VALUES as readonly string[]).includes(value)
? (value as Position9)
: sp.default,
);
</script>
<div class="control position-control">
<span class="position-label">{label}</span>
<div class="grid">
{#each POSITION9_VALUES as position (position)}
<button
type="button"
class="cell {current === position ? 'active' : ''}"
aria-label={position}
aria-pressed={current === position}
onclick={() => onchange?.(position)}
>
<span class="dot"></span>
</button>
{/each}
</div>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.position-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.grid {
display: inline-grid;
grid-template-columns: repeat(3, var(--space-xl));
gap: var(--space-s);
}
.cell {
display: grid;
place-items: center;
width: var(--space-xl);
height: var(--space-xl);
padding: 0;
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
cursor: pointer;
}
.cell:hover {
border-color: var(--color-main);
}
.dot {
width: var(--space-s);
height: var(--space-s);
border-radius: 50%;
background: var(--color-border);
}
.active {
border-color: var(--color-main);
background: var(--color-main);
}
.active .dot {
background: var(--color-text);
}
</style>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { FieldSpec, NumberSpec, SliderSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: number) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as NumberSpec | SliderSpec);
const current = $derived(
typeof value === "number" && Number.isFinite(value) ? value : sp.default,
);
function handle(e: Event) {
onchange?.(Number((e.target as HTMLInputElement).value));
}
</script>
<label class="control">
<span>
{label}
<output>{current}</output>
</span>
<input
type="range"
min={sp.min ?? 0}
max={sp.max ?? 100}
step={sp.step ?? 1}
value={current}
oninput={handle}
/>
</label>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.control > span {
display: flex;
justify-content: space-between;
}
.control output {
color: var(--color-text);
}
input[type="range"] {
width: 100%;
accent-color: var(--color-main);
color: var(--color-text);
}
</style>
@@ -0,0 +1,51 @@
<script lang="ts">
import type { FieldSpec, SelectSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: string) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as SelectSpec);
const current = $derived(
typeof value === "string" && sp.options.some((o) => o.value === value)
? value
: sp.default,
);
</script>
<label class="control">
<span>{label}</span>
<select
class="select-field"
value={current}
oninput={(e) => onchange?.((e.target as HTMLSelectElement).value)}
>
{#each sp.options as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</label>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.select-field {
width: 100%;
padding: var(--space-m) var(--space-l);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
}
</style>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { FieldSpec, TextSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: string) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as TextSpec);
const current = $derived(typeof value === "string" ? value : sp.default);
</script>
<label class="control">
<span>{label}</span>
<input
class="text-field"
type="text"
value={current}
placeholder={sp.placeholder}
oninput={(e) => onchange?.((e.target as HTMLInputElement).value)}
/>
</label>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.text-field {
width: 100%;
padding: var(--space-m) var(--space-l);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
}
</style>