Palette: - Add `fuchsia` (~H334) and `steel` as the 32nd/33rd named scales; rename radix-scales.ts -> color-scales.ts and drop the "Radix parity" framing (seeded from Radix, but the palette is ours). - generatePalette: per-family nearest-hue donor (L-per-hue) so bright hues (yellow/amber/lime) stay vivid instead of landing muddy. Theme builder (web/routes/temas): - estudio: new "palette-first" color mode — character (vivacity/tone/neutral) regenerates the 33 families; roles are selected by hue harmony or manually and applied live to real components via --primitive-* overrides. - paleta: palette generator + role harmony chooser (the model, visualized). Color guards (THEMING.md §25.2/§25.4): - G1: completeColorRoleMap throws when a hierarchy alias and a valenced intent share a scale; the builder picker excludes intent-occupied scales. - G2: re-expose role slots bg2*2 / separator*6 / borderHover*8 / textStrong*12 (13 slots) — real UI/a11y needs the original nine had dropped. - G3: document the CVD / WCAG 1.4.1 rule (an intent needs a non-color cue) plus the affirm/fulfill activation distinction; audit-grounded. - G4: palette-invariant.test.ts checks the delta-E floor on every generator's OUTPUT (multi-step, monochrome-exempt); clamp builder tone to +0.08. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>menubar-v4-safe
parent
f58948aa07
commit
044221e4c7
@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseColor, scaleToTemplate } from '$color';
|
||||
import { COLOR_SCALE_STEPS } from './config-types';
|
||||
import { THEME_BASE_LIGHT_COLOR_SCALES, THEME_BASE_DARK_COLOR_SCALES } from './themes/base';
|
||||
import { generatePalette, type HueAnchor } from './generate-palette';
|
||||
|
||||
// The recipe's donor must be CHROMATIC — it supplies the chroma envelope. A gray
|
||||
// donor (chroma ~0) would flatten every family to gray. Pick the first colourful one.
|
||||
const donorName =
|
||||
Object.keys(THEME_BASE_LIGHT_COLOR_SCALES).find(
|
||||
(n) => parseColor(THEME_BASE_LIGHT_COLOR_SCALES[n]['9'])[1] > 0.08
|
||||
) ?? Object.keys(THEME_BASE_LIGHT_COLOR_SCALES)[0];
|
||||
const templateLight = scaleToTemplate(
|
||||
COLOR_SCALE_STEPS.map((s) => THEME_BASE_LIGHT_COLOR_SCALES[donorName][s])
|
||||
);
|
||||
const templateDark = scaleToTemplate(
|
||||
COLOR_SCALE_STEPS.map((s) => THEME_BASE_DARK_COLOR_SCALES[donorName][s])
|
||||
);
|
||||
|
||||
// Fixed hue anchors — the palette's structure (names + hues), the "32 names".
|
||||
const anchors: HueAnchor[] = [
|
||||
{ name: 'red', hue: 25 },
|
||||
{ name: 'amber', hue: 75 },
|
||||
{ name: 'yellow', hue: 100 },
|
||||
{ name: 'teal', hue: 180 },
|
||||
{ name: 'blue', hue: 260 },
|
||||
{ name: 'plum', hue: 330 },
|
||||
{ name: 'neutral', hue: 55 }
|
||||
];
|
||||
|
||||
const byName = (pal: ReturnType<typeof generatePalette>, name: string) =>
|
||||
pal.find((p) => p.name === name)!;
|
||||
|
||||
describe('generatePalette — one recipe, N rotations', () => {
|
||||
it('shares the LIGHTNESS envelope across every family (that is what makes them a set)', () => {
|
||||
const pal = generatePalette(anchors, templateLight, { vivacity: 1 });
|
||||
const red = byName(pal, 'red').oklch;
|
||||
const blue = byName(pal, 'blue').oklch;
|
||||
const teal = byName(pal, 'teal').oklch;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
expect(red[i][0]).toBeCloseTo(blue[i][0], 6);
|
||||
expect(red[i][0]).toBeCloseTo(teal[i][0], 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('rotates the HUE per anchor (step 9 lands on the anchor exactly)', () => {
|
||||
const pal = generatePalette(anchors, templateLight);
|
||||
expect(byName(pal, 'red').oklch[8][2]).toBeCloseTo(25, 3);
|
||||
expect(byName(pal, 'teal').oklch[8][2]).toBeCloseTo(180, 3);
|
||||
expect(byName(pal, 'blue').oklch[8][2]).toBeCloseTo(260, 3);
|
||||
});
|
||||
|
||||
it('VIVACITY is the master saturation — pastel vs neon', () => {
|
||||
const pastel = byName(generatePalette(anchors, templateLight, { vivacity: 0.5 }), 'red').oklch;
|
||||
const neon = byName(generatePalette(anchors, templateLight, { vivacity: 1.6 }), 'red').oklch;
|
||||
expect(neon[8][1]).toBeGreaterThan(pastel[8][1] * 2.5);
|
||||
});
|
||||
|
||||
it('keeps NEUTRALS near-gray (tinted, never pure), exempt from vivacity', () => {
|
||||
const neutral = byName(
|
||||
generatePalette(anchors, templateLight, { vivacity: 1.6, neutralChroma: 0.008 }),
|
||||
'neutral'
|
||||
).oklch;
|
||||
expect(neutral[8][1]).toBeLessThan(0.02);
|
||||
});
|
||||
|
||||
it('TONE shifts the whole palette lighter / darker', () => {
|
||||
const base = byName(generatePalette(anchors, templateLight, { toneShift: 0 }), 'blue').oklch;
|
||||
const lighter = byName(
|
||||
generatePalette(anchors, templateLight, { toneShift: 0.08 }),
|
||||
'blue'
|
||||
).oklch;
|
||||
expect(lighter[8][0]).toBeGreaterThan(base[8][0]);
|
||||
});
|
||||
|
||||
it('DARK mode is regenerated (dark template) — step 1 is dark, not inverted', () => {
|
||||
const dark = byName(generatePalette(anchors, templateDark), 'blue').oklch;
|
||||
const light = byName(generatePalette(anchors, templateLight), 'blue').oklch;
|
||||
expect(dark[0][0]).toBeLessThan(0.3); // step 1 near-black in dark
|
||||
expect(light[0][0]).toBeGreaterThan(0.9); // step 1 near-white in light
|
||||
});
|
||||
|
||||
it('SET mode (donor per family) lifts bright hues — yellow solid sits far lighter than red (no mud)', () => {
|
||||
const donors = Object.fromEntries(
|
||||
['red', 'amber', 'yellow', 'teal', 'blue', 'plum'].map((n) => [
|
||||
n,
|
||||
scaleToTemplate(COLOR_SCALE_STEPS.map((s) => THEME_BASE_LIGHT_COLOR_SCALES[n][s]))
|
||||
])
|
||||
);
|
||||
const pal = generatePalette(anchors, donors, { vivacity: 1 });
|
||||
const yellowL = byName(pal, 'yellow').oklch[8][0];
|
||||
const redL = byName(pal, 'red').oklch[8][0];
|
||||
// A muddy yellow is a yellow forced to red's L. Per-family donors keep it bright.
|
||||
expect(yellowL).toBeGreaterThan(redL + 0.15);
|
||||
});
|
||||
|
||||
it('emits valid sRGB hex (gamut-mapped)', () => {
|
||||
const pal = generatePalette(anchors, templateLight, { vivacity: 1.8 });
|
||||
for (const scale of pal) for (const hex of scale.hex) expect(hex).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
});
|
||||
|
||||
it('SAMPLE — a warm palette, one recipe rotated (shown for validation)', () => {
|
||||
const pal = generatePalette(anchors, templateLight, { vivacity: 1.15, neutralHue: 55 });
|
||||
const rows = pal.map((s) => {
|
||||
const [L, C, H] = s.oklch[8];
|
||||
return `${s.name.padEnd(8)} step9=${s.hex[8]} L=${L.toFixed(3)} C=${C.toFixed(3)} H=${Math.round(H)}°`;
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n=== generatePalette sample (vivacity 1.15) ===\n' + rows.join('\n') + '\n');
|
||||
expect(pal).toHaveLength(anchors.length);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Estudio — the Eidos theme builder. A route-tool for theme authors that
|
||||
* dogfoods the framework and previews REAL components under the drafted theme,
|
||||
* applied GLOBALLY at :root (so real components re-skin) while the control
|
||||
* chrome (literal colours) stays put.
|
||||
*/
|
||||
import { onDestroy } from 'svelte'
|
||||
import { createActiveUix, setActiveUix } from '$active-uix'
|
||||
import { Soma } from '$soma/core/soma.svelte'
|
||||
import { siumLangs } from '$sium/langs/langs'
|
||||
import { secsLangs } from '$libs/secs'
|
||||
import { ActiveEidos } from '$uix/eidos'
|
||||
import type { ScalingKey } from '$uix/eidos/lib/config-types'
|
||||
import '@/uix/eidos/index.css'
|
||||
import '@/uix/eidos/generated/palette.css'
|
||||
import './_lib/estudio.css'
|
||||
|
||||
import { ThemeDraftStore, createDraftStore } from './_lib/store.svelte'
|
||||
import { applyDraft, draftToCode, draftToJson } from './_lib/apply'
|
||||
import ColorPanel from './panels/ColorPanel.svelte'
|
||||
import TypographyPanel from './panels/TypographyPanel.svelte'
|
||||
import ShapePanel from './panels/ShapePanel.svelte'
|
||||
import SurfacesPanel from './panels/SurfacesPanel.svelte'
|
||||
import SpacePanel from './panels/SpacePanel.svelte'
|
||||
import GradientsPanel from './panels/GradientsPanel.svelte'
|
||||
import AdvancedPanel from './panels/AdvancedPanel.svelte'
|
||||
import Preview from './panels/Preview.svelte'
|
||||
|
||||
let store = $state(createDraftStore())
|
||||
let themeName = $state('mi-tema')
|
||||
|
||||
// ── Runtime boot (uix + soma + eidos), mode/density/scaling from the store ──
|
||||
const uix = createActiveUix({
|
||||
langs: { schema: { sium: siumLangs, secs: secsLangs }, defaultLocale: 'es' as const },
|
||||
events: { sound: false, haptic: false }
|
||||
})
|
||||
setActiveUix(uix)
|
||||
Soma.create()
|
||||
|
||||
// One shared registry; the structural sources all re-read from the store on
|
||||
// notify, so the value arg is ignored (eidos re-reads `get()`).
|
||||
const listeners = new Set<() => void>()
|
||||
function onChange<T>(handler: (value: T) => void): () => void {
|
||||
const fn = () => handler(undefined as T)
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
const eidos = ActiveEidos.create({
|
||||
applyDom: true,
|
||||
modeSource: { get: () => store.mode, onChange },
|
||||
densitySource: { get: () => store.density, onChange },
|
||||
scalingSource: { get: () => String(store.scaling) as ScalingKey, onChange }
|
||||
})
|
||||
// Notify eidos sources when the structural axes change.
|
||||
$effect(() => {
|
||||
void store.mode
|
||||
void store.density
|
||||
void store.scaling
|
||||
for (const l of listeners) l()
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
eidos.dispose()
|
||||
uix.dispose()
|
||||
})
|
||||
|
||||
// ── The single reactive application ──
|
||||
$effect(() => {
|
||||
applyDraft(uix, eidos, store)
|
||||
})
|
||||
|
||||
// ── Tabs ──
|
||||
type Tab = { id: string; label: string }
|
||||
const TABS: Tab[] = [
|
||||
{ id: 'color', label: 'Color' },
|
||||
{ id: 'type', label: 'Tipografía' },
|
||||
{ id: 'shape', label: 'Forma' },
|
||||
{ id: 'surfaces', label: 'Superficies' },
|
||||
{ id: 'space', label: 'Espacio' },
|
||||
{ id: 'gradients', label: 'Gradientes' },
|
||||
{ id: 'advanced', label: 'Avanzado' }
|
||||
]
|
||||
let tab = $state('color')
|
||||
|
||||
let showExport = $state(false)
|
||||
let exportKind = $state<'code' | 'json'>('code')
|
||||
const exportText = $derived(exportKind === 'code' ? draftToCode(store) : draftToJson(store))
|
||||
|
||||
function reset(): void {
|
||||
store = createDraftStore()
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Estudio · Eidos theme builder</title></svelte:head>
|
||||
|
||||
<div class="studio">
|
||||
<header class="topbar">
|
||||
<span class="brand">Estudio <small>eidos theme builder</small></span>
|
||||
|
||||
<div class="top-group">
|
||||
<span class="top-label">tema</span>
|
||||
<input class="field-select" style="inline-size: 8rem;" bind:value={themeName} spellcheck="false" />
|
||||
</div>
|
||||
|
||||
<div class="top-group">
|
||||
<span class="top-label">apariencia</span>
|
||||
<div class="seg">
|
||||
<button class="seg-btn" data-active={store.mode === 'light'} onclick={() => (store.mode = 'light')}>◐ claro</button>
|
||||
<button class="seg-btn" data-active={store.mode === 'dark'} onclick={() => (store.mode = 'dark')}>◑ oscuro</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-ghost" onclick={reset}>restablecer</button>
|
||||
<button class="btn-primary" onclick={() => (showExport = !showExport)}>Exportar</button>
|
||||
</header>
|
||||
|
||||
<div class="lab">
|
||||
<aside class="controls">
|
||||
<nav class="tabbar">
|
||||
{#each TABS as t}
|
||||
<button class="tab" data-active={tab === t.id} onclick={() => (tab = t.id)}>{t.label}</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if tab === 'color'}
|
||||
<ColorPanel {store} />
|
||||
{:else if tab === 'type'}
|
||||
<TypographyPanel {store} />
|
||||
{:else if tab === 'shape'}
|
||||
<ShapePanel {store} />
|
||||
{:else if tab === 'surfaces'}
|
||||
<SurfacesPanel {store} />
|
||||
{:else if tab === 'space'}
|
||||
<SpacePanel {store} />
|
||||
{:else if tab === 'gradients'}
|
||||
<GradientsPanel {store} />
|
||||
{:else if tab === 'advanced'}
|
||||
<AdvancedPanel {store} />
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
{#if showExport}
|
||||
<div class="panel" style="background:#14141f;border:1px solid #26263a;border-radius:16px;margin-block-end:1.25rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<div class="seg">
|
||||
<button class="seg-btn" data-active={exportKind === 'code'} onclick={() => (exportKind = 'code')}>código</button>
|
||||
<button class="seg-btn" data-active={exportKind === 'json'} onclick={() => (exportKind = 'json')}>JSON</button>
|
||||
</div>
|
||||
<button class="btn-ghost" style="margin-inline-start:auto;" onclick={() => navigator.clipboard?.writeText(exportText)}>copiar</button>
|
||||
<button class="btn-ghost" onclick={() => (showExport = false)}>cerrar</button>
|
||||
</div>
|
||||
<code class="export-code">{exportText}</code>
|
||||
</div>
|
||||
{/if}
|
||||
<Preview {store} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,2 @@
|
||||
// Theme studio — the reference-grade theme builder. Live runtime only.
|
||||
export const prerender = false
|
||||
@ -0,0 +1,378 @@
|
||||
/**
|
||||
* apply.ts — turns a {@link ThemeDraftStore} into real runtime writes.
|
||||
*
|
||||
* Three mechanisms, disjoint token sets, applied at `:root` (GLOBAL) so real
|
||||
* components re-skin (a subtree override does not, because capa-4 aliases bind
|
||||
* at `:root` — verified). Per-family shape is the one scoped axis today, emitted
|
||||
* as its own `[data-{c}]` rules via `dom.writeStyle`.
|
||||
*
|
||||
* - perceptual axes (color-derive · type · shape · depth · space) → applyTheme
|
||||
* - token overrides (assign-mode primitives · fonts · radius · advanced) → setCssVariables
|
||||
* - per-family shape → dom.writeStyle
|
||||
* - structure (density · scaling) → dom.writeStyle
|
||||
*/
|
||||
import type { ActiveUix } from '$active-uix'
|
||||
import { aurora, deepen, sheen, halo, type ActiveEidos, type ThemeSeed } from '$uix/eidos'
|
||||
import type { Gradient } from '$libs/gradient'
|
||||
import {
|
||||
parseColor,
|
||||
generateScale,
|
||||
scaleToTemplate,
|
||||
pickNearestTemplate,
|
||||
oklchToHex,
|
||||
type ScaleSteps
|
||||
} from '$color'
|
||||
import { THEME_BASE_LIGHT_COLOR_SCALES, THEME_BASE_DARK_COLOR_SCALES } from '$uix/eidos/lib/themes/base'
|
||||
import type { ThemeDraftStore } from './store.svelte'
|
||||
import { generateStudioPalette } from './generate'
|
||||
import {
|
||||
COLOR_SCALE_STEPS,
|
||||
FAMILY_CORNER_SHAPE,
|
||||
SHAPE_SCOPE_TARGETS,
|
||||
VALENCED_INTENTS,
|
||||
ASSIGNABLE_HIERARCHY,
|
||||
CANONICAL_INTENT_SCALES
|
||||
} from './palette'
|
||||
|
||||
const SHAPE_SCOPE_STYLE_ID = 'estudio-shape-scope'
|
||||
|
||||
// Density + scaling are driven through the ActiveEidos density/scaling SOURCES
|
||||
// (see +page), not written here — the engine projects data-density/data-scaling
|
||||
// and emits --density-*/--scaling from its own config, so a manual :root write
|
||||
// would just fight its static block.
|
||||
|
||||
/** `--primitive-{role}-{1..12}` (+ alpha) → the chosen PRESET palette scale. */
|
||||
function assignScaleVars(role: string, scale: string): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const step of COLOR_SCALE_STEPS) {
|
||||
out[`--primitive-${role}-${step}`] = `var(--scale-${scale}-${step})`
|
||||
out[`--primitive-${role}-a${step}`] = `var(--scale-${scale}-a${step})`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Donor L/C curves from the base theme scales, per mode — the shape `generateScale` morphs. */
|
||||
function templatesFor(mode: 'light' | 'dark'): Record<string, ScaleSteps> {
|
||||
const scales = mode === 'dark' ? THEME_BASE_DARK_COLOR_SCALES : THEME_BASE_LIGHT_COLOR_SCALES
|
||||
const templates: Record<string, ScaleSteps> = {}
|
||||
for (const [name, scale] of Object.entries(scales)) {
|
||||
templates[name] = scaleToTemplate(COLOR_SCALE_STEPS.map((step) => scale[step]))
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
/** Fabricate a 12-step hex scale from one brand hex (the engine's `generateScale`). */
|
||||
function generateSteps(hex: string, templates: Record<string, ScaleSteps>): string[] {
|
||||
const seed = parseColor(hex)
|
||||
return generateScale(seed, templates[pickNearestTemplate(seed, templates)]).map(oklchToHex)
|
||||
}
|
||||
|
||||
/** A CUSTOM role: fabricate its 12-step scale from one brand hex (the engine). */
|
||||
function customScaleVars(
|
||||
role: string,
|
||||
hex: string,
|
||||
templates: Record<string, ScaleSteps>
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
generateSteps(hex, templates).forEach((h, i) => {
|
||||
out[`--primitive-${role}-${i + 1}`] = h
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** A GENERATED role: bind its 12 primitives to the palette-first generated hex steps. */
|
||||
function genScaleVars(role: string, steps: readonly string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
steps.forEach((h, i) => {
|
||||
out[`--primitive-${role}-${i + 1}`] = h
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** The 12 hex steps of a named preset scale, for the active mode. */
|
||||
function presetSteps(scaleName: string, mode: 'light' | 'dark'): readonly string[] {
|
||||
const scale = (mode === 'dark' ? THEME_BASE_DARK_COLOR_SCALES : THEME_BASE_LIGHT_COLOR_SCALES)[scaleName]
|
||||
return scale ? COLOR_SCALE_STEPS.map((s) => scale[s]) : []
|
||||
}
|
||||
|
||||
const FROST_STYLE_ID = 'estudio-frost'
|
||||
|
||||
function depthOverrides(store: ThemeDraftStore): Record<string, Record<string, string>> {
|
||||
const i = store.elevation
|
||||
const drop = (y: number, blur: number, alpha: number): string =>
|
||||
`0 ${Math.round(y)}px ${Math.round(blur)}px rgb(0 0 0 / ${alpha.toFixed(3)})`
|
||||
const shadowByPlane: Record<string, string> = {
|
||||
raised: drop(1 + i * 3, 5 + i * 16, 0.05 + i * 0.22),
|
||||
overlay: drop(8 + i * 24, 24 + i * 46, 0.14 + i * 0.4),
|
||||
modal: drop(16 + i * 30, 40 + i * 60, 0.2 + i * 0.45)
|
||||
}
|
||||
const out: Record<string, Record<string, string>> = {}
|
||||
for (const plane of ['raised', 'overlay', 'modal']) {
|
||||
const c = store.planes[plane]
|
||||
out[plane] = {
|
||||
shadow: shadowByPlane[plane],
|
||||
blur: `${c.blur}px`,
|
||||
translucency: `${c.translucency}%`,
|
||||
halo: `inset 0 1px 0 0 color-mix(in oklab, white ${c.halo}%, transparent)`,
|
||||
border: `color-mix(in oklab, white ${c.border}%, transparent)`
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Frost-by-default rule per plane (mirrors `cristalThemeCss`, scoped to the preview). */
|
||||
function frostCss(store: ThemeDraftStore): string {
|
||||
if (!store.frostByDefault) return ''
|
||||
return ['raised', 'overlay', 'modal']
|
||||
.map((plane) => {
|
||||
const c = store.planes[plane]
|
||||
const filter = `blur(${c.blur}px) saturate(${(c.saturate / 100).toFixed(2)})`
|
||||
return (
|
||||
`.preview [data-depth='${plane}']{` +
|
||||
`background-color:color-mix(in srgb, var(--color-surface-raised) ${c.translucency}%, transparent);` +
|
||||
`backdrop-filter:${filter};-webkit-backdrop-filter:${filter};}`
|
||||
)
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function shapeScopeCss(store: ThemeDraftStore): string {
|
||||
let css = ''
|
||||
for (const t of SHAPE_SCOPE_TARGETS) {
|
||||
const fam = store.shapeByFamily[t.key]
|
||||
if (fam) css += `${t.selector} { corner-shape: ${FAMILY_CORNER_SHAPE[fam]}; }\n`
|
||||
}
|
||||
return css
|
||||
}
|
||||
|
||||
/** Apply the whole draft. Idempotent — call from a `$effect`. */
|
||||
export function applyDraft(uix: ActiveUix, eidos: ActiveEidos, store: ThemeDraftStore): void {
|
||||
const selector = ':root'
|
||||
const tokens: Record<string, string> = {}
|
||||
const templates = templatesFor(store.mode)
|
||||
|
||||
// palette mode: regenerate the whole named palette from character, once.
|
||||
const gen =
|
||||
store.colorMode === 'palette'
|
||||
? generateStudioPalette(store.mode, {
|
||||
vivacity: store.paletteVivacity,
|
||||
tone: store.paletteTone,
|
||||
neutralHue: store.paletteNeutralHue
|
||||
})
|
||||
: null
|
||||
|
||||
// ── Color ── generated palette (roles select from it) · preset/custom · or seed derive
|
||||
if (gen) {
|
||||
for (const role of ASSIGNABLE_HIERARCHY) {
|
||||
const steps = gen[store.roles[role]]
|
||||
if (steps) Object.assign(tokens, genScaleVars(role, steps))
|
||||
}
|
||||
for (const intent of VALENCED_INTENTS) {
|
||||
const custom = store.intentCustom[intent]
|
||||
if (custom) {
|
||||
Object.assign(tokens, customScaleVars(intent, custom, templates))
|
||||
continue
|
||||
}
|
||||
const scale = store.intents[intent] !== 'auto' ? store.intents[intent] : CANONICAL_INTENT_SCALES[intent]
|
||||
const steps = gen[scale]
|
||||
if (steps) Object.assign(tokens, genScaleVars(intent, steps))
|
||||
}
|
||||
} else if (store.colorMode === 'assign') {
|
||||
for (const role of ASSIGNABLE_HIERARCHY) {
|
||||
const custom = store.roleCustom[role]
|
||||
Object.assign(
|
||||
tokens,
|
||||
custom ? customScaleVars(role, custom, templates) : assignScaleVars(role, store.roles[role])
|
||||
)
|
||||
}
|
||||
for (const intent of VALENCED_INTENTS) {
|
||||
const custom = store.intentCustom[intent]
|
||||
if (custom) {
|
||||
Object.assign(tokens, customScaleVars(intent, custom, templates))
|
||||
continue
|
||||
}
|
||||
const scale = store.intents[intent]
|
||||
if (scale && scale !== 'auto') Object.assign(tokens, assignScaleVars(intent, scale))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Typography (families) ──
|
||||
if (store.fontPrimary) tokens['--font-family-primary'] = store.fontPrimary
|
||||
if (store.fontMono) tokens['--font-family-mono'] = store.fontMono
|
||||
if (store.fontDisplay) tokens['--font-family-display'] = store.fontDisplay
|
||||
|
||||
// ── Shape (global) ──
|
||||
tokens['--radius-factor'] = String(store.radiusFactor)
|
||||
|
||||
// ── Surfaces (tinted backgrounds) ──
|
||||
if (store.surfaceTint > 0) {
|
||||
const tint = store.surfaceTint
|
||||
tokens['--color-surface-default'] =
|
||||
`color-mix(in oklab, var(--color-primary-solid) ${tint}%, var(--primitive-neutral-1))`
|
||||
tokens['--color-surface-raised'] =
|
||||
`color-mix(in oklab, var(--color-primary-solid) ${Math.round(tint * 0.7)}%, var(--primitive-neutral-2))`
|
||||
}
|
||||
|
||||
// ── Advanced: border · focus · hovers · rings ──
|
||||
const mix = (pct: number) => `color-mix(in srgb, currentColor ${pct}%, transparent)`
|
||||
tokens['--border-width'] = `${store.borderWidth}px`
|
||||
tokens['--border-style'] = store.borderStyle
|
||||
tokens['--focus-ring-width'] = `${store.focusWidth}px`
|
||||
tokens['--focus-ring-offset'] = `${store.focusOffset}px`
|
||||
tokens['--focus-ring-inner-width'] = `${store.focusInnerWidth}px`
|
||||
if (store.focusRingColor) tokens['--focus-ring-color'] = store.focusRingColor
|
||||
tokens['--ring-inset-width'] = `${store.insetRingWidth}px`
|
||||
if (store.insetRingColor) tokens['--ring-inset-color'] = store.insetRingColor
|
||||
tokens['--state-hover'] = mix(store.stateHover)
|
||||
tokens['--state-press'] = mix(store.statePress)
|
||||
tokens['--state-selected'] = mix(store.stateSelected)
|
||||
|
||||
// ── Gradients (role-derived) ──
|
||||
const gradients: Record<string, Gradient> = {}
|
||||
if (store.auroraOn) {
|
||||
gradients.aurora = aurora(store.auroraRoles, { seed: 3, alpha: store.auroraAlpha })
|
||||
}
|
||||
if (store.brandOn) {
|
||||
const factory = store.brandFactory === 'sheen' ? sheen : store.brandFactory === 'halo' ? halo : deepen
|
||||
gradients.brand = factory(store.brandRole)
|
||||
}
|
||||
|
||||
// ── Perceptual axes (applyTheme) ──
|
||||
const seed: ThemeSeed = {
|
||||
...(store.colorMode === 'derive' ? { color: store.seed } : {}),
|
||||
type: {
|
||||
base: store.typeBase,
|
||||
ratio: store.typeRatio,
|
||||
...(store.typeFluid ? { ratioMax: store.typeRatio * 1.12 } : {})
|
||||
},
|
||||
shape: { smoothing: store.smoothing },
|
||||
depth: depthOverrides(store),
|
||||
space: {
|
||||
base: store.spaceBase,
|
||||
...(store.spaceGrowth > 1 ? { growth: store.spaceGrowth } : {})
|
||||
},
|
||||
...(Object.keys(gradients).length ? { gradient: gradients } : {})
|
||||
}
|
||||
|
||||
const roleOverrides = Object.fromEntries(
|
||||
Object.entries(store.roleOverrides).filter(([, v]) => v)
|
||||
)
|
||||
const themeResult = eidos.applyTheme(seed, {
|
||||
selector,
|
||||
color:
|
||||
store.colorMode === 'derive'
|
||||
? {
|
||||
mode: store.mode,
|
||||
variant: store.variant,
|
||||
temper: store.temper,
|
||||
...(Object.keys(roleOverrides).length ? { overrides: roleOverrides } : {})
|
||||
}
|
||||
: { mode: store.mode }
|
||||
})
|
||||
eidos.setCssVariables(tokens, { selector, strict: false })
|
||||
|
||||
// ── Per-family shape + frost-by-default (scoped blocks) ──
|
||||
uix.dom.writeStyle(SHAPE_SCOPE_STYLE_ID, shapeScopeCss(store))
|
||||
uix.dom.writeStyle(FROST_STYLE_ID, frostCss(store))
|
||||
|
||||
// ── The RESOLVED palette (12 steps per role) — so the panel can SHOW it ──
|
||||
store.resolvedPalette = computePalette(store, templates, themeResult.color?.roles, gen)
|
||||
}
|
||||
|
||||
/** The final 12-step palette per role — from generated, preset, custom color, or seed derivation. */
|
||||
function computePalette(
|
||||
store: ThemeDraftStore,
|
||||
templates: Record<string, ScaleSteps>,
|
||||
derived: readonly { role: string; steps: readonly string[] }[] | undefined,
|
||||
gen: Record<string, string[]> | null
|
||||
): Record<string, readonly string[]> {
|
||||
const palette: Record<string, readonly string[]> = {}
|
||||
const derivedFor = (role: string) => derived?.find((r) => r.role === role)?.steps
|
||||
|
||||
for (const role of ASSIGNABLE_HIERARCHY) {
|
||||
if (gen) {
|
||||
palette[role] = gen[store.roles[role]] ?? presetSteps(store.roles[role], store.mode)
|
||||
} else if (store.colorMode === 'assign') {
|
||||
const custom = store.roleCustom[role]
|
||||
palette[role] = custom ? generateSteps(custom, templates) : presetSteps(store.roles[role], store.mode)
|
||||
} else {
|
||||
palette[role] = derivedFor(role) ?? presetSteps(store.roles[role], store.mode)
|
||||
}
|
||||
}
|
||||
for (const intent of VALENCED_INTENTS) {
|
||||
const canonical = CANONICAL_INTENT_SCALES[intent as keyof typeof CANONICAL_INTENT_SCALES]
|
||||
if (gen) {
|
||||
const custom = store.intentCustom[intent]
|
||||
if (custom) palette[intent] = generateSteps(custom, templates)
|
||||
else {
|
||||
const scale = store.intents[intent] !== 'auto' ? store.intents[intent] : canonical
|
||||
palette[intent] = gen[scale] ?? presetSteps(scale, store.mode)
|
||||
}
|
||||
} else if (store.colorMode === 'assign') {
|
||||
const custom = store.intentCustom[intent]
|
||||
if (custom) palette[intent] = generateSteps(custom, templates)
|
||||
else {
|
||||
const scale = store.intents[intent]
|
||||
palette[intent] = presetSteps(scale && scale !== 'auto' ? scale : canonical, store.mode)
|
||||
}
|
||||
} else {
|
||||
palette[intent] = derivedFor(intent) ?? presetSteps(canonical, store.mode)
|
||||
}
|
||||
}
|
||||
return palette
|
||||
}
|
||||
|
||||
/** A copy-pasteable snippet of what the draft does. */
|
||||
export function draftToCode(store: ThemeDraftStore): string {
|
||||
const lines: string[] = []
|
||||
if (store.colorMode === 'assign') {
|
||||
lines.push('// color: assign roles → palette scales')
|
||||
lines.push(`eidos.setCssVariables({`)
|
||||
for (const role of ASSIGNABLE_HIERARCHY)
|
||||
lines.push(` '--primitive-${role}-9': 'var(--scale-${store.roles[role]}-9)', // …1-12`)
|
||||
lines.push(`}, { strict: false })`)
|
||||
} else {
|
||||
lines.push(`// color: derive from a brand seed`)
|
||||
lines.push(`eidos.applyColorScheme('${store.seed}', { variant: '${store.variant}', temper: ${store.temper} })`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push(`eidos.applyTheme({`)
|
||||
lines.push(` type: { base: ${store.typeBase}, ratio: ${store.typeRatio}${store.typeFluid ? ', ratioMax: …' : ''} },`)
|
||||
lines.push(` shape: { smoothing: ${store.smoothing} },`)
|
||||
lines.push(` space: { base: ${store.spaceBase}${store.spaceGrowth > 1 ? `, growth: ${store.spaceGrowth}` : ''} },`)
|
||||
lines.push(` depth: { raised, overlay, modal } // elevation ${store.elevation}`)
|
||||
lines.push(`})`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** The full draft as JSON (round-trippable studio document). */
|
||||
export function draftToJson(store: ThemeDraftStore): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
mode: store.mode,
|
||||
density: store.density,
|
||||
scaling: store.scaling,
|
||||
color:
|
||||
store.colorMode === 'assign'
|
||||
? { mode: 'assign', roles: store.roles, intents: store.intents }
|
||||
: { mode: 'derive', seed: store.seed, variant: store.variant, temper: store.temper },
|
||||
type: { fontPrimary: store.fontPrimary, base: store.typeBase, ratio: store.typeRatio, fluid: store.typeFluid },
|
||||
shape: {
|
||||
radiusFactor: store.radiusFactor,
|
||||
smoothing: store.smoothing,
|
||||
family: store.familyGlobal,
|
||||
byFamily: store.shapeByFamily,
|
||||
pressSqueeze: store.pressSqueeze
|
||||
},
|
||||
depth: {
|
||||
elevation: store.elevation,
|
||||
frostByDefault: store.frostByDefault,
|
||||
planes: store.planes
|
||||
},
|
||||
space: { base: store.spaceBase, growth: store.spaceGrowth },
|
||||
advanced: { borderWidth: store.borderWidth, focusInnerWidth: store.focusInnerWidth, stateHover: store.stateHover }
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Estudio chrome — the control panel + shell styling. LITERAL colours on
|
||||
* purpose: the whole page applies the drafted theme at :root, so the chrome
|
||||
* must NOT re-skin (only the preview does). Shared across all panels.
|
||||
*/
|
||||
.studio {
|
||||
min-block-size: 100vh;
|
||||
background: radial-gradient(130% 90% at 50% -10%, #1c1b2e, transparent 55%), #0b0b11;
|
||||
color: #e7e7ef;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem clamp(1rem, 3vw, 2rem);
|
||||
background: #10101a;
|
||||
border-block-end: 1px solid #26263a;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.brand { font-weight: 700; letter-spacing: -0.01em; margin-inline-end: auto; display: flex; align-items: baseline; gap: 0.5rem; }
|
||||
.brand small { font-family: monospace; font-size: 0.68rem; color: #8b8ba7; font-weight: 400; }
|
||||
.top-group { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.top-label { font-family: monospace; font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.08em; color: #8b8ba7; }
|
||||
|
||||
.lab { display: grid; grid-template-columns: 1fr; gap: 1.25rem; align-items: start; padding: 1.25rem clamp(1rem, 3vw, 2rem) 4rem; }
|
||||
@media (min-width: 64rem) { .lab { grid-template-columns: 25rem 1fr; } }
|
||||
|
||||
.controls { display: flex; flex-direction: column; gap: 0; border-radius: 16px; background: #14141f; border: 1px solid #26263a; overflow: hidden; position: sticky; inset-block-start: 4.2rem; max-block-size: calc(100vh - 5.5rem); }
|
||||
|
||||
.tabbar { display: flex; flex-wrap: wrap; gap: 0.15rem; padding: 0.5rem; background: #0f0f18; border-block-end: 1px solid #26263a; }
|
||||
.tab { font-size: 0.74rem; padding: 0.35rem 0.6rem; border-radius: 8px; cursor: pointer; background: transparent; color: #b6b6c8; border: 1px solid transparent; }
|
||||
.tab:hover { background: #1d1d2b; }
|
||||
.tab[data-active='true'] { background: #6366f1; color: #fff; }
|
||||
|
||||
.panel { display: flex; flex-direction: column; gap: 1.25rem; padding: 1.1rem; overflow-y: auto; }
|
||||
.panel-title { font-size: 0.95rem; font-weight: 600; margin: 0; }
|
||||
.panel-hint { font-size: 0.74rem; color: #9a9ab0; margin: -0.75rem 0 0; line-height: 1.4; }
|
||||
|
||||
.ctl { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.ctl-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; }
|
||||
.ctl-label { font-family: monospace; font-size: 0.64rem; text-transform: uppercase; letter-spacing: 0.09em; color: #9a9ab0; }
|
||||
.ctl-val { font-family: monospace; font-size: 0.7rem; color: #d8d8e8; }
|
||||
.ctl-hint { font-size: 0.7rem; color: #7a7a92; line-height: 1.4; }
|
||||
.slider { inline-size: 100%; accent-color: #818cf8; }
|
||||
|
||||
.seg { display: inline-flex; gap: 0.25rem; background: #0f0f18; padding: 0.2rem; border-radius: 9px; border: 1px solid #26263a; }
|
||||
.seg-btn { font-size: 0.72rem; padding: 0.3rem 0.6rem; border-radius: 7px; cursor: pointer; background: transparent; color: #b6b6c8; border: 0; }
|
||||
.seg-btn[data-active='true'] { background: #6366f1; color: #fff; }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.chip { font-size: 0.7rem; padding: 0.3rem 0.6rem; border-radius: 999px; cursor: pointer; background: #1b1b28; color: #b6b6c8; border: 1px solid #2a2a3c; }
|
||||
.chip[data-active='true'] { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||
|
||||
.field-select { font: inherit; font-size: 0.76rem; padding: 0.4rem 0.55rem; border-radius: 8px; background: #0f0f18; color: #e7e7ef; border: 1px solid #2a2a3c; inline-size: 100%; }
|
||||
.color-input { inline-size: 2.4rem; block-size: 2.4rem; border-radius: 8px; border: 1px solid #2a2a3c; background: none; cursor: pointer; padding: 2px; }
|
||||
|
||||
.role-block { display: flex; flex-direction: column; gap: 0.45rem; padding: 0.7rem; border-radius: 11px; background: #10101a; border: 1px solid #23233444; }
|
||||
.role-name { display: flex; align-items: center; gap: 0.45rem; font-size: 0.78rem; font-weight: 600; }
|
||||
.role-dot { inline-size: 1rem; block-size: 1rem; border-radius: 5px; box-shadow: inset 0 0 0 1px #ffffff22; }
|
||||
.role-scale { font-family: monospace; font-size: 0.66rem; color: #9a9ab0; margin-inline-start: auto; }
|
||||
|
||||
.swatch-grid { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.swatch-group-label { font-family: monospace; font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.08em; color: #6f6f88; }
|
||||
.swatch-row { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.swatch { inline-size: 1.35rem; block-size: 1.35rem; border-radius: 6px; cursor: pointer; border: 2px solid transparent; box-shadow: inset 0 0 0 1px #ffffff1c; padding: 0; }
|
||||
.swatch[data-active='true'] { border-color: #fff; }
|
||||
.swatch-grid.compact .swatch { inline-size: 1.1rem; block-size: 1.1rem; border-radius: 5px; }
|
||||
|
||||
.matrix { display: grid; grid-template-columns: auto 1fr; gap: 0.4rem 0.7rem; align-items: center; }
|
||||
.matrix-label { font-size: 0.74rem; color: #cfcfe0; }
|
||||
|
||||
.export-code { display: block; white-space: pre; font-family: monospace; font-size: 0.64rem; line-height: 1.5; padding: 0.8rem; border-radius: 10px; background: #0c0c14; color: #a5b4fc; border: 1px solid #26263a; overflow: auto; max-block-size: 22rem; }
|
||||
.btn-ghost { font-size: 0.74rem; padding: 0.4rem 0.75rem; border-radius: 8px; cursor: pointer; background: transparent; color: #cfcfe0; border: 1px solid #2a2a3c; }
|
||||
.btn-ghost:hover { border-color: #3d3d55; }
|
||||
.btn-primary { font-size: 0.74rem; padding: 0.4rem 0.8rem; border-radius: 8px; cursor: pointer; background: #6366f1; color: #fff; border: 0; }
|
||||
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Palette-first generation for the studio — the `/temas/paleta` model wired into
|
||||
* the builder. Character knobs (vivacity / tone / neutral hue) regenerate the whole
|
||||
* named palette from ONE recipe (`generatePalette`, framework); the hierarchy roles
|
||||
* are then a SELECTION from it (manual or by hue harmony). Pure — no DOM.
|
||||
*/
|
||||
import { parseColor, scaleToTemplate, hueDistance } from '$color'
|
||||
import { COLOR_SCALE_STEPS } from '$uix/eidos/lib/config-types'
|
||||
import { THEME_BASE_LIGHT_COLOR_SCALES, THEME_BASE_DARK_COLOR_SCALES } from '$uix/eidos/lib/themes/base'
|
||||
import { generatePalette, type HueAnchor } from '$uix/eidos/lib/generate-palette'
|
||||
|
||||
export interface PaletteCharacter {
|
||||
/** Master saturation — pastel ≈ 0.4, normal = 1, vibrant ≈ 1.4, neon ≈ 1.8. */
|
||||
vivacity: number
|
||||
/** Lightness shift on the solid anchor (whole palette lighter / darker). */
|
||||
tone: number
|
||||
/** Neutral tint hue (the grays get a tiny chroma at this hue). */
|
||||
neutralHue: number
|
||||
}
|
||||
|
||||
const LIGHT = THEME_BASE_LIGHT_COLOR_SCALES
|
||||
const NAMES = Object.keys(LIGHT)
|
||||
|
||||
// Fixed hue anchors + their native hue & chroma (parsed from the shipped step 9).
|
||||
// Native chroma keeps browns/metals muted; native hue is the family identity.
|
||||
const ANCHORS: HueAnchor[] = NAMES.map((n) => {
|
||||
const [, c, h] = parseColor(LIGHT[n]['9'])
|
||||
return { name: n, hue: h, chroma: c }
|
||||
})
|
||||
const GRAY_NAMES = NAMES.filter((n) => parseColor(LIGHT[n]['9'])[1] < 0.03)
|
||||
const CHROMATIC = NAMES.filter((n) => parseColor(LIGHT[n]['9'])[1] > 0.04)
|
||||
const CHROMATIC_ANCHORS = ANCHORS.filter((a) => !GRAY_NAMES.includes(a.name))
|
||||
|
||||
function donorSet(mode: 'light' | 'dark'): Record<string, ReturnType<typeof scaleToTemplate>> {
|
||||
const scales = mode === 'dark' ? THEME_BASE_DARK_COLOR_SCALES : LIGHT
|
||||
return Object.fromEntries(
|
||||
CHROMATIC.map((n) => [n, scaleToTemplate(COLOR_SCALE_STEPS.map((s) => scales[n][s]))])
|
||||
)
|
||||
}
|
||||
|
||||
/** Generate the whole named palette at a chosen character → name → 12 hex steps. */
|
||||
export function generateStudioPalette(
|
||||
mode: 'light' | 'dark',
|
||||
ch: PaletteCharacter
|
||||
): Record<string, string[]> {
|
||||
const pal = generatePalette(ANCHORS, donorSet(mode), {
|
||||
vivacity: ch.vivacity,
|
||||
toneShift: ch.tone,
|
||||
neutralHue: ch.neutralHue,
|
||||
neutralNames: GRAY_NAMES
|
||||
})
|
||||
return Object.fromEntries(pal.map((s) => [s.name, [...s.hex]]))
|
||||
}
|
||||
|
||||
/** Hue-relationship presets — roles are a SELECTION by hue offset from a base. */
|
||||
export const HARMONIES = {
|
||||
'análogo': [0, 30, -30],
|
||||
'tríada': [0, 120, 240],
|
||||
'complementario': [0, 180, 90],
|
||||
'material +60': [0, 30, 60]
|
||||
} as const satisfies Record<string, readonly number[]>
|
||||
export type Harmony = keyof typeof HARMONIES
|
||||
|
||||
const norm = (h: number) => ((h % 360) + 360) % 360
|
||||
const EMPTY: ReadonlySet<string> = new Set()
|
||||
|
||||
/** Nearest chromatic family to a hue, skipping scales already occupied by an intent (G1). */
|
||||
const nearestFamily = (hue: number, occupied: ReadonlySet<string> = EMPTY): string => {
|
||||
const pool = CHROMATIC_ANCHORS.filter((a) => !occupied.has(a.name))
|
||||
const src = pool.length ? pool : CHROMATIC_ANCHORS // never strand: fall back if all occupied
|
||||
return src.reduce((b, a) => (hueDistance(a.hue, hue) < hueDistance(b.hue, hue) ? a : b)).name
|
||||
}
|
||||
|
||||
/**
|
||||
* primary/secondary/tertiary family names from a base hue + harmony relationship.
|
||||
* `occupied` = scales a valenced intent already owns; hierarchy skips them so a brand
|
||||
* alias and an intent never land on the same scale (THEMING §25.2 / guard G1).
|
||||
*/
|
||||
export function harmonyRoles(
|
||||
baseHue: number,
|
||||
harmony: Harmony,
|
||||
occupied: ReadonlySet<string> = EMPTY
|
||||
): { primary: string; secondary: string; tertiary: string } {
|
||||
const [primary, secondary, tertiary] = HARMONIES[harmony].map((off) =>
|
||||
nearestFamily(norm(baseHue + off), occupied)
|
||||
)
|
||||
return { primary, secondary, tertiary }
|
||||
}
|
||||
|
||||
export { NAMES as PALETTE_NAMES, GRAY_NAMES, CHROMATIC as CHROMATIC_NAMES }
|
||||
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Palette data for the theme studio — the 33 shipped scales grouped for the
|
||||
* swatch grids, plus the canonical role / slot / intent vocab re-exported from
|
||||
* the engine so the panels stay in sync with `config-types`.
|
||||
*
|
||||
* The `--scale-{name}-*` tokens for ALL 33 scales exist only when the opt-in
|
||||
* full palette CSS is loaded (`@/uix/eidos/generated/palette.css`); the slim
|
||||
* foundation ships only role-referenced scales. The studio imports it.
|
||||
*/
|
||||
import {
|
||||
HIERARCHY_COLOR_ROLES,
|
||||
COLOR_ROLE_SLOTS,
|
||||
CANONICAL_INTENT_SCALES,
|
||||
COLOR_SCALE_STEPS
|
||||
} from '$uix/eidos/lib/config-types'
|
||||
import { INTENTS, type Intent } from '$uix/intent'
|
||||
|
||||
export { HIERARCHY_COLOR_ROLES, COLOR_ROLE_SLOTS, CANONICAL_INTENT_SCALES, COLOR_SCALE_STEPS, INTENTS }
|
||||
|
||||
/** The 33 palette scales, grouped by family (display only). */
|
||||
export const PALETTE_GROUPS: { label: string; names: readonly string[] }[] = [
|
||||
{ label: 'Grises', names: ['gray', 'mauve', 'slate', 'sage', 'olive', 'sand'] },
|
||||
{
|
||||
label: 'Colores',
|
||||
names: [
|
||||
'tomato', 'red', 'ruby', 'crimson', 'pink', 'fuchsia', 'plum', 'purple', 'violet', 'iris',
|
||||
'indigo', 'blue', 'cyan', 'teal', 'jade', 'green', 'grass', 'brown', 'orange'
|
||||
]
|
||||
},
|
||||
{ label: 'Brillantes', names: ['amber', 'yellow', 'lime', 'mint', 'sky'] },
|
||||
{ label: 'Metales', names: ['gold', 'bronze', 'steel'] }
|
||||
]
|
||||
|
||||
/** Flat list of every scale name. */
|
||||
export const SCALE_NAMES: readonly string[] = PALETTE_GROUPS.flatMap((g) => g.names)
|
||||
|
||||
/** Gray-family scales — the candidates for the `neutral` role. */
|
||||
export const GRAY_SCALES: readonly string[] = PALETTE_GROUPS[0].names
|
||||
|
||||
/** Hierarchy roles the user assigns (primary/secondary/tertiary + neutral chrome). */
|
||||
export const ASSIGNABLE_HIERARCHY = [...HIERARCHY_COLOR_ROLES, 'neutral'] as const
|
||||
export type AssignableRole = (typeof ASSIGNABLE_HIERARCHY)[number]
|
||||
|
||||
/** The 5 valenced intents (neutral is the gray, handled as a role). */
|
||||
export const VALENCED_INTENTS = INTENTS.filter((i): i is Intent => i !== 'neutral') as readonly Intent[]
|
||||
|
||||
/** Surface / content / border chrome slots (neutral-driven). */
|
||||
export const SURFACE_SLOTS = ['default', 'raised', 'overlay', 'muted', 'backdrop'] as const
|
||||
export const CONTENT_SLOTS = ['primary', 'secondary', 'muted', 'disabled'] as const
|
||||
export const BORDER_SLOTS = ['subtle', 'default', 'strong'] as const
|
||||
|
||||
/** The 4 shape families (corner-shape). */
|
||||
export const SHAPE_FAMILIES = ['rounded', 'continuous', 'cut', 'scoop'] as const
|
||||
export type ShapeFamily = (typeof SHAPE_FAMILIES)[number]
|
||||
|
||||
/** family → `corner-shape` value. `continuous` reads the live `--shape-smoothing`. */
|
||||
export const FAMILY_CORNER_SHAPE: Record<ShapeFamily, string> = {
|
||||
rounded: 'round',
|
||||
continuous: 'superellipse(var(--shape-smoothing))',
|
||||
cut: 'bevel',
|
||||
scoop: 'scoop'
|
||||
}
|
||||
|
||||
/** The component families whose shape can be scoped independently (the matrix). */
|
||||
export const SHAPE_SCOPE_TARGETS: { key: string; label: string; selector: string }[] = [
|
||||
{ key: 'button', label: 'Botones', selector: '[data-button]' },
|
||||
{ key: 'card', label: 'Cards', selector: '[data-card]' },
|
||||
{ key: 'dialog', label: 'Diálogos', selector: '[data-dialog-content]' },
|
||||
{ key: 'popover', label: 'Popovers', selector: '[data-popover-content]' },
|
||||
{ key: 'badge', label: 'Badges', selector: '[data-badge]' },
|
||||
{ key: 'field', label: 'Campos', selector: '[data-field-control], [data-search-field]' }
|
||||
]
|
||||
|
||||
export const SCALING_STEPS = ['90', '95', '100', '105', '110'] as const
|
||||
export const DENSITIES = ['compact', 'comfortable', 'spacious'] as const
|
||||
export type DensityKey = (typeof DENSITIES)[number]
|
||||
|
||||
/** Density → the two layout scalars (mirrors the docs shell). */
|
||||
export const DENSITY_SCALES: Record<DensityKey, { space: number; control: number }> = {
|
||||
compact: { space: 0.84, control: 0.9 },
|
||||
comfortable: { space: 1, control: 1 },
|
||||
spacious: { space: 1.16, control: 1.12 }
|
||||
}
|
||||
|
||||
/** A few ready font stacks (until the @font-face loader UI lands). */
|
||||
export const FONT_STACKS: { label: string; stack: string }[] = [
|
||||
{ label: 'System', stack: 'system-ui, sans-serif' },
|
||||
{ label: 'Geometric', stack: '"Avenir Next", "Segoe UI", system-ui, sans-serif' },
|
||||
{ label: 'Serif', stack: 'Georgia, "Times New Roman", serif' },
|
||||
{ label: 'Rounded', stack: '"SF Pro Rounded", "Segoe UI", system-ui, sans-serif' },
|
||||
{ label: 'Mono', stack: 'ui-monospace, "Cascadia Code", monospace' }
|
||||
]
|
||||
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* ThemeDraftStore — the whole editable theme, as one reactive object (Svelte 5
|
||||
* runes). A superset of the framework `ThemeSeed`: the six perceptual axes PLUS
|
||||
* the assign-mode color roles, font families, structure (mode/density/scaling),
|
||||
* per-family shape, and advanced tokens. `apply.ts` reads this and drives the
|
||||
* real runtime (`applyTheme` + `setCssVariables` + scoped `writeStyle`).
|
||||
*/
|
||||
import type { AssignableRole, DensityKey, ShapeFamily } from './palette'
|
||||
import type { Harmony } from './generate'
|
||||
|
||||
export type Mode = 'light' | 'dark'
|
||||
export type ColorMode = 'palette' | 'assign' | 'derive'
|
||||
export type SchemeVariant = 'tonal' | 'vibrant' | 'monochrome'
|
||||
|
||||
/** Per-plane glass material cues (the depth channel's material model). */
|
||||
export interface PlaneCues {
|
||||
translucency: number // % of surface that survives (frost opacity)
|
||||
blur: number // px backdrop blur
|
||||
halo: number // white % of the top rim-light
|
||||
border: number // white % of the glass border
|
||||
saturate: number // % vibrancy boost behind the glass
|
||||
}
|
||||
|
||||
/** The 3 elevated planes cristal retunes. */
|
||||
export const GLASS_PLANES = ['raised', 'overlay', 'modal'] as const
|
||||
|
||||
export class ThemeDraftStore {
|
||||
// ── Appearance / structure ──────────────────────────────────────────────
|
||||
mode = $state<Mode>('dark')
|
||||
density = $state<DensityKey>('comfortable')
|
||||
scaling = $state(100) // 90..110
|
||||
|
||||
// ── Color ─────────────────────────────────────────────────────────────────
|
||||
colorMode = $state<ColorMode>('palette')
|
||||
/**
|
||||
* palette mode — CHARACTER: regenerates the whole named palette from one recipe
|
||||
* (`generateStudioPalette`). Roles then SELECT from it (manual or by harmony).
|
||||
*/
|
||||
paletteVivacity = $state(1) // 0.3 pastel .. 1.9 neon (chroma multiplier)
|
||||
paletteTone = $state(0) // -0.15 .. 0.15 lightness shift
|
||||
paletteNeutralHue = $state(55) // 0..360 neutral tint hue
|
||||
/** palette mode — harmony helper: base hue + relationship → sets primary/secondary/tertiary. */
|
||||
paletteBaseHue = $state(252)
|
||||
paletteHarmony = $state<Harmony>('análogo')
|
||||
/** assign/palette mode: hierarchy/neutral role → palette scale name */
|
||||
roles = $state<Record<AssignableRole, string>>({
|
||||
primary: 'indigo',
|
||||
secondary: 'cyan',
|
||||
tertiary: 'jade',
|
||||
neutral: 'slate'
|
||||
})
|
||||
/**
|
||||
* assign mode: a CUSTOM color per role (hex). When set it WINS over the preset
|
||||
* scale — the engine (`generateScale`) fabricates its 12-step scale. This is how
|
||||
* you build a custom palette: pick your own colors instead of the 31 presets.
|
||||
*/
|
||||
roleCustom = $state<Record<AssignableRole, string>>({
|
||||
primary: '',
|
||||
secondary: '',
|
||||
tertiary: '',
|
||||
neutral: ''
|
||||
})
|
||||
/** assign mode: a CUSTOM color per valenced intent (hex; '' = preset/auto). */
|
||||
intentCustom = $state<Record<string, string>>({
|
||||
affirm: '',
|
||||
fulfill: '',
|
||||
risk: '',
|
||||
threat: '',
|
||||
loss: ''
|
||||
})
|
||||
/** valenced intent → 'auto' (canonical) | palette scale name */
|
||||
intents = $state<Record<string, string>>({
|
||||
affirm: 'auto',
|
||||
fulfill: 'auto',
|
||||
risk: 'auto',
|
||||
threat: 'auto',
|
||||
loss: 'auto'
|
||||
})
|
||||
/** derive mode */
|
||||
seed = $state('#6366f1')
|
||||
variant = $state<SchemeVariant>('tonal')
|
||||
temper = $state(0) // 0..1
|
||||
/** derive mode — pin individual roles (role → hex); unpinned roles derive */
|
||||
roleOverrides = $state<Record<string, string>>({})
|
||||
|
||||
/**
|
||||
* The RESOLVED palette — the actual 12 hex steps per role, computed on apply
|
||||
* (from a preset scale, your custom color, or the seed derivation). This is
|
||||
* what the Color panel renders so you SEE the palette you built.
|
||||
*/
|
||||
resolvedPalette = $state<Record<string, readonly string[]>>({})
|
||||
|
||||
// ── Typography ──────────────────────────────────────────────────────────────
|
||||
fontPrimary = $state('system-ui, sans-serif')
|
||||
fontDisplay = $state('')
|
||||
fontMono = $state('ui-monospace, monospace')
|
||||
typeBase = $state(16)
|
||||
typeRatio = $state(1.25)
|
||||
typeFluid = $state(false)
|
||||
|
||||
// ── Shape ─────────────────────────────────────────────────────────────────
|
||||
radiusFactor = $state(1) // 0..2 global roundness
|
||||
smoothing = $state(2) // 1 arc → 2 squircle → 3 superellipse
|
||||
familyGlobal = $state<ShapeFamily>('continuous')
|
||||
/** per-family override: target key → family ('' = follow global) */
|
||||
shapeByFamily = $state<Record<string, ShapeFamily | ''>>({})
|
||||
pressSqueeze = $state(true)
|
||||
|
||||
// ── Depth / surfaces ────────────────────────────────────────────────────────
|
||||
elevation = $state(0.5) // 0 flat → 1 dramatic (scales the drop shadow)
|
||||
/** Frost by default on every elevated [data-depth] surface (the cristal move). */
|
||||
frostByDefault = $state(false)
|
||||
/**
|
||||
* Per-plane glass cues (raised/overlay/modal): translucency % · blur px ·
|
||||
* halo (white %) · glass border (white %) · saturate % (vibrancy).
|
||||
* The full material the depth channel models — what `cristal` retunes.
|
||||
*/
|
||||
planes = $state<Record<string, PlaneCues>>({
|
||||
raised: { translucency: 80, blur: 8, halo: 5, border: 6, saturate: 100 },
|
||||
overlay: { translucency: 68, blur: 12, halo: 7, border: 10, saturate: 100 },
|
||||
modal: { translucency: 80, blur: 16, halo: 9, border: 14, saturate: 100 }
|
||||
})
|
||||
/** app background: flat surface vs a brand-tinted gradient */
|
||||
bgMode = $state<'solid' | 'gradient'>('solid')
|
||||
/** % of primary mixed into the surface tokens (tinted backgrounds) */
|
||||
surfaceTint = $state(0)
|
||||
|
||||
// ── Space ─────────────────────────────────────────────────────────────────
|
||||
spaceBase = $state(4) // px unit
|
||||
spaceGrowth = $state(1) // 1 = static; >1 = fluid
|
||||
|
||||
// ── Gradients ───────────────────────────────────────────────────────────────
|
||||
/** Aurora mesh (role-derived) → `--gradient-aurora`; the cristal stage. */
|
||||
auroraOn = $state(false)
|
||||
auroraRoles = $state<string[]>(['primary', 'affirm', 'secondary', 'loss', 'risk'])
|
||||
auroraAlpha = $state(0.8)
|
||||
/** Brand sweep (a role ramp) → `--gradient-brand`. */
|
||||
brandOn = $state(false)
|
||||
brandFactory = $state<'deepen' | 'sheen' | 'halo'>('deepen')
|
||||
brandRole = $state('primary')
|
||||
|
||||
// ── Advanced: border · focus · hovers · rings ───────────────────────────────
|
||||
borderWidth = $state(1) // px
|
||||
borderStyle = $state<'solid' | 'dashed' | 'dotted'>('solid')
|
||||
focusWidth = $state(2) // px (outer focus ring)
|
||||
focusOffset = $state(2) // px (gap)
|
||||
focusInnerWidth = $state(0) // px (inner focus ring)
|
||||
focusRingColor = $state('') // '' = follow primary; hex = custom
|
||||
insetRingWidth = $state(2) // px (crisp inset ring)
|
||||
insetRingColor = $state('') // '' = currentColor; hex = custom
|
||||
stateHover = $state(8) // % (state-layer hover)
|
||||
statePress = $state(12) // %
|
||||
stateSelected = $state(12) // %
|
||||
}
|
||||
|
||||
/** Fresh store with defaults — used by "restablecer". */
|
||||
export function createDraftStore(): ThemeDraftStore {
|
||||
return new ThemeDraftStore()
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Theme studio — the `ThemeDraft` model + its application glue.
|
||||
*
|
||||
* A `ThemeDraft` is a superset of the framework's `ThemeSeed`: the six
|
||||
* perceptual axes `applyTheme` composes (color · type · shape · depth · space ·
|
||||
* gradient) PLUS the non-seed knobs a theme author still wants — font families,
|
||||
* global roundness, and a raw escape hatch for advanced tokens (focus-ring /
|
||||
* state-layer / border widths …).
|
||||
*
|
||||
* It maps onto the three application mechanisms `ActiveEidos` already exposes,
|
||||
* which touch DISJOINT token sets (no cascade conflict):
|
||||
*
|
||||
* - `seed` → `eidos.applyTheme(seed, { selector })`
|
||||
* - `fonts` · `radiusFactor` · `advanced`
|
||||
* → `eidos.setCssVariables(map, { selector, strict: false })`
|
||||
* - structure (mode / density / scaling) is driven by the page's reactive
|
||||
* sources, NOT here — it lives on `data-*` attrs / `modeSource`.
|
||||
*
|
||||
* Route-level glue (single consumer, per the "ruta-herramienta" decision). If a
|
||||
* reusable `<ThemeBuilder>` is extracted later, this graduates to `src/` with a
|
||||
* unit test; today its only real risk is DOM integration, verified live in the
|
||||
* studio preview.
|
||||
*/
|
||||
import type { ActiveEidos, ThemeSeed } from '$uix/eidos'
|
||||
import type { EidosCssVariableMap } from '$uix/eidos/lib/config-types'
|
||||
|
||||
export type ThemeMode = 'light' | 'dark'
|
||||
|
||||
/** Font-family overrides → `--font-family-{key}`. */
|
||||
export interface ThemeFonts {
|
||||
readonly primary?: string
|
||||
readonly display?: string
|
||||
readonly mono?: string
|
||||
}
|
||||
|
||||
/** The CSS selector the whole draft is scoped to (the preview region). */
|
||||
export const PREVIEW_SELECTOR = '[data-estudio-preview]'
|
||||
|
||||
/**
|
||||
* Everything the studio edits. The `seed` axes go through `applyTheme`; the
|
||||
* rest are token overrides through `setCssVariables`.
|
||||
*/
|
||||
export interface ThemeDraft {
|
||||
readonly seed: ThemeSeed
|
||||
readonly fonts?: ThemeFonts
|
||||
/** Global roundness knob → `--radius-factor` (foundation default 1). */
|
||||
readonly radiusFactor?: number
|
||||
/** Raw token → value escape hatch (focus-ring, state-layer, border, opacity…). */
|
||||
readonly advanced?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/** Map the non-seed axes of a draft to a `--token` map for `setCssVariables`. */
|
||||
export function draftToVariables(draft: ThemeDraft): EidosCssVariableMap {
|
||||
const out: Record<string, string> = {}
|
||||
if (draft.fonts?.primary) out['--font-family-primary'] = draft.fonts.primary
|
||||
if (draft.fonts?.display) out['--font-family-display'] = draft.fonts.display
|
||||
if (draft.fonts?.mono) out['--font-family-mono'] = draft.fonts.mono
|
||||
if (draft.radiusFactor !== undefined) out['--radius-factor'] = String(draft.radiusFactor)
|
||||
if (draft.advanced) Object.assign(out, draft.advanced)
|
||||
return out
|
||||
}
|
||||
|
||||
export interface ApplyDraftOptions {
|
||||
readonly selector?: string
|
||||
/** Force the color axis donor mode (light/dark) for the scoped scheme. */
|
||||
readonly mode?: ThemeMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a whole draft to the (scoped) preview: the seed via `applyTheme`, the
|
||||
* font / radius / advanced overrides via `setCssVariables`. Both target the same
|
||||
* selector so the preview region — and nothing else — re-skins.
|
||||
*/
|
||||
export function applyDraft(
|
||||
eidos: ActiveEidos,
|
||||
draft: ThemeDraft,
|
||||
options: ApplyDraftOptions = {}
|
||||
): void {
|
||||
const selector = options.selector ?? PREVIEW_SELECTOR
|
||||
eidos.applyTheme(draft.seed, {
|
||||
selector,
|
||||
color: options.mode ? { mode: options.mode } : undefined
|
||||
})
|
||||
// `strict: false` — some knobs (`--radius-factor`) and every advanced token
|
||||
// (`--state-*`, hand-written in archetypes.css) live outside the generated
|
||||
// contract, so a contract-validated write would reject them.
|
||||
eidos.setCssVariables(draftToVariables(draft), { selector, strict: false })
|
||||
}
|
||||
|
||||
/** Revert every mechanism the draft touched. */
|
||||
export function clearDraft(eidos: ActiveEidos): void {
|
||||
eidos.clearTheme()
|
||||
eidos.clearCssVariables()
|
||||
}
|
||||
|
||||
/** A copy-pasteable `applyTheme(…)` (+ overrides) snippet for the current draft. */
|
||||
export function draftToCode(draft: ThemeDraft): string {
|
||||
let code = `eidos.applyTheme(${JSON.stringify(draft.seed, null, 2)})`
|
||||
const vars = draftToVariables(draft)
|
||||
if (Object.keys(vars).length > 0) {
|
||||
code += `\n\neidos.setCssVariables(\n ${JSON.stringify(vars, null, 2).replace(/\n/g, '\n ')},\n { strict: false }\n)`
|
||||
}
|
||||
return code
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import type { ThemeDraftStore } from '../_lib/store.svelte'
|
||||
|
||||
let { store }: { store: ThemeDraftStore } = $props()
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<h2 class="panel-title">Avanzado — bordes · focus · hovers · rings</h2>
|
||||
<p class="panel-hint">Ejes de token que casi ningún builder expone.</p>
|
||||
|
||||
<!-- ── Border ── -->
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">border · ancho</span><span class="ctl-val">{store.borderWidth}px</span></div>
|
||||
<input class="slider" type="range" min="0" max="4" step="0.5" bind:value={store.borderWidth} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">border · estilo</span>
|
||||
<div class="seg">
|
||||
{#each ['solid', 'dashed', 'dotted'] as const as s}
|
||||
<button class="seg-btn" data-active={store.borderStyle === s} onclick={() => (store.borderStyle = s)}>{s}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Focus ring ── -->
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">focus ring · ancho exterior</span><span class="ctl-val">{store.focusWidth}px</span></div>
|
||||
<input class="slider" type="range" min="0" max="4" step="0.5" bind:value={store.focusWidth} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">focus ring · offset (hueco)</span><span class="ctl-val">{store.focusOffset}px</span></div>
|
||||
<input class="slider" type="range" min="0" max="4" step="0.5" bind:value={store.focusOffset} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">focus ring · anillo interior</span><span class="ctl-val">{store.focusInnerWidth}px</span></div>
|
||||
<input class="slider" type="range" min="0" max="3" step="0.5" bind:value={store.focusInnerWidth} />
|
||||
<p class="ctl-hint">0 = un solo marco exterior; >0 enciende el segundo anillo interior en todos los campos.</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Inset ring ── -->
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">inset-ring · ancho (anillo interior nítido)</span><span class="ctl-val">{store.insetRingWidth}px</span></div>
|
||||
<input class="slider" type="range" min="0" max="4" step="0.5" bind:value={store.insetRingWidth} />
|
||||
</div>
|
||||
|
||||
<!-- ── Hovers / state-layer ── -->
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">state-layer · hover</span><span class="ctl-val">{store.stateHover}%</span></div>
|
||||
<input class="slider" type="range" min="0" max="24" step="1" bind:value={store.stateHover} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">state-layer · press</span><span class="ctl-val">{store.statePress}%</span></div>
|
||||
<input class="slider" type="range" min="0" max="30" step="1" bind:value={store.statePress} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">state-layer · selected</span><span class="ctl-val">{store.stateSelected}%</span></div>
|
||||
<input class="slider" type="range" min="0" max="30" step="1" bind:value={store.stateSelected} />
|
||||
<p class="ctl-hint">Tinte neutro de interacción (modelo MD3, theme-adaptive vía currentColor).</p>
|
||||
</div>
|
||||
|
||||
<p class="ctl-hint">Opacidad, motion (durations/eases), z-index e icon: siguiente — el motor ya los tokeniza.</p>
|
||||
</div>
|
||||
@ -0,0 +1,365 @@
|
||||
<script lang="ts">
|
||||
import type { ThemeDraftStore } from '../_lib/store.svelte'
|
||||
import SwatchGrid from '../_lib/SwatchGrid.svelte'
|
||||
import {
|
||||
ASSIGNABLE_HIERARCHY,
|
||||
GRAY_SCALES,
|
||||
SCALE_NAMES,
|
||||
VALENCED_INTENTS,
|
||||
CANONICAL_INTENT_SCALES,
|
||||
type AssignableRole
|
||||
} from '../_lib/palette'
|
||||
import {
|
||||
generateStudioPalette,
|
||||
harmonyRoles,
|
||||
HARMONIES,
|
||||
GRAY_NAMES as GEN_GRAY,
|
||||
CHROMATIC_NAMES,
|
||||
type Harmony
|
||||
} from '../_lib/generate'
|
||||
import { safeParseColor, oklchToHex, type Oklch } from '$color'
|
||||
|
||||
let { store }: { store: ThemeDraftStore } = $props()
|
||||
|
||||
const ROLE_LABELS: Record<AssignableRole, string> = {
|
||||
primary: 'Primario',
|
||||
secondary: 'Secundario',
|
||||
tertiary: 'Terciario',
|
||||
neutral: 'Neutral'
|
||||
}
|
||||
const INTENT_LABELS: Record<string, string> = {
|
||||
affirm: 'Affirm', fulfill: 'Fulfill', risk: 'Risk', threat: 'Threat', loss: 'Loss'
|
||||
}
|
||||
|
||||
// Only ONE swatch grid is mounted at a time (the active role) — keeps the
|
||||
// panel light. `Radix` shows one accent grid; we show one per selected role.
|
||||
let activeRole = $state<AssignableRole>('primary')
|
||||
|
||||
// ── palette-first model (the /temas/paleta model, live) ──────────────────
|
||||
const genPalette = $derived(
|
||||
generateStudioPalette(store.mode, {
|
||||
vivacity: store.paletteVivacity,
|
||||
tone: store.paletteTone,
|
||||
neutralHue: store.paletteNeutralHue
|
||||
})
|
||||
)
|
||||
const vivLabel = $derived(
|
||||
store.paletteVivacity < 0.6 ? 'pastel' : store.paletteVivacity < 0.9 ? 'suave' : store.paletteVivacity < 1.25 ? 'normal' : store.paletteVivacity < 1.55 ? 'vibrante' : 'neón'
|
||||
)
|
||||
// family name → which role points at it (for the tags in the generated list)
|
||||
const roleOf = $derived.by(() => {
|
||||
const m: Record<string, string> = {}
|
||||
for (const r of ASSIGNABLE_HIERARCHY) m[store.roles[r]] = r
|
||||
return m
|
||||
})
|
||||
// scales a valenced intent already owns — hierarchy pickers skip these so a brand
|
||||
// alias and an intent never share a scale (THEMING §25.2 / guard G1).
|
||||
const intentScales = $derived.by(() => {
|
||||
const s = new Set<string>()
|
||||
for (const iv of VALENCED_INTENTS) {
|
||||
if (store.intentCustom[iv]) continue // custom hex, not a named scale
|
||||
s.add(store.intents[iv] !== 'auto' ? store.intents[iv] : CANONICAL_INTENT_SCALES[iv])
|
||||
}
|
||||
return s
|
||||
})
|
||||
function applyHarmony(): void {
|
||||
const r = harmonyRoles(store.paletteBaseHue, store.paletteHarmony, intentScales)
|
||||
store.roles = { ...store.roles, primary: r.primary, secondary: r.secondary, tertiary: r.tertiary }
|
||||
}
|
||||
|
||||
// The RAW colour of the active role, in OKLCH — the full colour space, no moods.
|
||||
// L (lightness) · C (chroma / saturation, up to P3) · H (hue). Editing any channel
|
||||
// writes a custom colour for the role; its scale is generated from it.
|
||||
const currentHex = $derived(
|
||||
store.roleCustom[activeRole] || store.resolvedPalette[activeRole]?.[8] || '#7c7c8a'
|
||||
)
|
||||
const oklch = $derived<Oklch>(safeParseColor(currentHex) ?? ([0.5, 0.15, 260] as unknown as Oklch))
|
||||
function setChannel(idx: number, val: number): void {
|
||||
const next: [number, number, number] = [oklch[0], oklch[1], oklch[2]]
|
||||
next[idx] = val
|
||||
store.roleCustom = { ...store.roleCustom, [activeRole]: oklchToHex(next as unknown as Oklch) }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet intents()}
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">intents · evaluativos</span>
|
||||
<p class="ctl-hint">Auto = convención del libro. Sobrescribe si tu marca lo pide.</p>
|
||||
{#each VALENCED_INTENTS as intent}
|
||||
<div class="intent-row">
|
||||
<span class="role-dot" style="background: {store.intentCustom[intent] || `var(--scale-${store.intents[intent] === 'auto' ? CANONICAL_INTENT_SCALES[intent] : store.intents[intent]}-9)`}"></span>
|
||||
<span class="intent-name">{INTENT_LABELS[intent]}</span>
|
||||
{#if store.intentCustom[intent]}
|
||||
<code class="override-val" style="flex:1;">{store.intentCustom[intent]}</code>
|
||||
<button class="chip" onclick={() => (store.intentCustom = { ...store.intentCustom, [intent]: '' })}>↺</button>
|
||||
{:else}
|
||||
<select class="field-select intent-select" bind:value={store.intents[intent]}>
|
||||
<option value="auto">auto · {CANONICAL_INTENT_SCALES[intent]}</option>
|
||||
{#each SCALE_NAMES as name}
|
||||
<option value={name}>{name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<input
|
||||
class="color-input sm"
|
||||
type="color"
|
||||
value={store.intentCustom[intent] || '#7c7c8a'}
|
||||
title="color propio"
|
||||
oninput={(e) => (store.intentCustom = { ...store.intentCustom, [intent]: e.currentTarget.value })}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="panel">
|
||||
<h2 class="panel-title">Color</h2>
|
||||
|
||||
<!-- The RESOLVED palette — the actual 12 steps per role (from seed / custom / preset) -->
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">tu paleta · 12 pasos por rol (el resultado)</span>
|
||||
<div class="palette-view">
|
||||
{#each [...ASSIGNABLE_HIERARCHY, ...VALENCED_INTENTS] as role}
|
||||
{#if store.resolvedPalette[role]?.length}
|
||||
<div class="pal-row">
|
||||
<span class="pal-name">{role}</span>
|
||||
<div class="pal-steps">
|
||||
{#each store.resolvedPalette[role] as step, i}
|
||||
<span class="pal-step" class:solid={i === 8} style="background: {step}" title="{role}-{i + 1}: {step}"></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">modelo</span>
|
||||
<div class="seg">
|
||||
<button class="seg-btn" data-active={store.colorMode === 'palette'} onclick={() => (store.colorMode = 'palette')}>Generar paleta</button>
|
||||
<button class="seg-btn" data-active={store.colorMode === 'assign'} onclick={() => (store.colorMode = 'assign')}>Asignar escalas</button>
|
||||
<button class="seg-btn" data-active={store.colorMode === 'derive'} onclick={() => (store.colorMode = 'derive')}>Derivar de semilla</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if store.colorMode === 'palette'}
|
||||
<p class="panel-hint">Tu <strong>paleta entera</strong> (33 familias) se genera desde el <strong>carácter</strong>; los roles la <strong>seleccionan</strong> — por armonía o a mano. El modelo de <code>/temas/paleta</code>, aplicado en vivo.</p>
|
||||
|
||||
<!-- CHARACTER — regenerates the whole palette at once -->
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">vivacidad</span><span class="ctl-val">{store.paletteVivacity.toFixed(2)} · {vivLabel}</span></div>
|
||||
<input class="slider" type="range" min="0.3" max="1.9" step="0.05" bind:value={store.paletteVivacity} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">tono (L)</span><span class="ctl-val">{store.paletteTone >= 0 ? '+' : ''}{store.paletteTone.toFixed(2)}</span></div>
|
||||
<!-- Upper bound clamped: beyond ~+0.08 the bright families whiten toward the gamut
|
||||
ceiling and merge (yellow=lime); the palette-invariant test verifies this range. -->
|
||||
<input class="slider" type="range" min="-0.15" max="0.08" step="0.01" bind:value={store.paletteTone} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">neutral · matiz</span><span class="ctl-val">{store.paletteNeutralHue}°</span></div>
|
||||
<input class="slider" type="range" min="0" max="360" step="1" bind:value={store.paletteNeutralHue} />
|
||||
</div>
|
||||
|
||||
<!-- HARMONY — sets primary/secondary/tertiary by hue relationship -->
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">rol base (hue) → armonía</span><span class="ctl-val">{store.paletteBaseHue}°</span></div>
|
||||
<input class="slider" type="range" min="0" max="360" step="1" bind:value={store.paletteBaseHue} oninput={applyHarmony} />
|
||||
<div class="seg" style="margin-top:0.4rem">
|
||||
{#each Object.keys(HARMONIES) as h}
|
||||
<button class="seg-btn" data-active={store.paletteHarmony === h} onclick={() => { store.paletteHarmony = h as Harmony; applyHarmony() }}>{h}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ROLES — pick the active role, then a family (manual, degree-free) -->
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">roles · elige el activo, luego una familia ↓</span>
|
||||
<div class="chips">
|
||||
{#each ASSIGNABLE_HIERARCHY as role}
|
||||
<button class="chip role-chip" data-active={activeRole === role} onclick={() => (activeRole = role)}>
|
||||
<span class="role-dot" style="background: {genPalette[store.roles[role]]?.[8] ?? '#888'}"></span>
|
||||
{ROLE_LABELS[role]} · {store.roles[role]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">{ROLE_LABELS[activeRole]} · familia (paleta generada)</span>
|
||||
<div class="fam-grid">
|
||||
{#each (activeRole === 'neutral' ? GEN_GRAY : CHROMATIC_NAMES.filter((n) => !intentScales.has(n))) as name}
|
||||
<button
|
||||
class="fam-sw"
|
||||
data-active={store.roles[activeRole] === name}
|
||||
title={name}
|
||||
style="background: {genPalette[name]?.[8]}"
|
||||
onclick={() => (store.roles = { ...store.roles, [activeRole]: name })}
|
||||
aria-label={name}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The generated palette (all 33) — with role tags -->
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">paleta generada · 33 familias</span>
|
||||
<div class="palette-view">
|
||||
{#each Object.entries(genPalette) as [name, steps]}
|
||||
<div class="pal-row" class:isrole={roleOf[name]}>
|
||||
<span class="pal-name">
|
||||
{#if roleOf[name]}<em class="rtag">{roleOf[name][0].toUpperCase()}</em>{/if}{name}
|
||||
</span>
|
||||
<div class="pal-steps">
|
||||
{#each steps as step, i}
|
||||
<span class="pal-step" class:solid={i === 8} style="background: {step}" title="{name}-{i + 1}: {step}"></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{@render intents()}
|
||||
{:else if store.colorMode === 'assign'}
|
||||
<p class="panel-hint">Tu <strong>paleta</strong>: cada rol = una escala <em>preset</em> (de las 33) <strong>o tu propio color</strong> (el motor le fabrica los 12 pasos).</p>
|
||||
|
||||
<!-- Role selector — pick which role you're assigning -->
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">rol · jerarquía + neutral</span>
|
||||
<div class="chips">
|
||||
{#each ASSIGNABLE_HIERARCHY as role}
|
||||
<button class="chip role-chip" data-active={activeRole === role} onclick={() => (activeRole = role)}>
|
||||
<span class="role-dot" style="background: {store.roleCustom[role] || `var(--scale-${store.roles[role]}-9)`}"></span>
|
||||
{ROLE_LABELS[role]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CUSTOM color for the active role — YOUR palette -->
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">{ROLE_LABELS[activeRole]} · color propio</span>
|
||||
<div class="override-row">
|
||||
<input
|
||||
class="color-input sm"
|
||||
type="color"
|
||||
value={store.roleCustom[activeRole] || '#7c7c8a'}
|
||||
oninput={(e) => (store.roleCustom = { ...store.roleCustom, [activeRole]: e.currentTarget.value })}
|
||||
/>
|
||||
{#if store.roleCustom[activeRole]}
|
||||
<code class="override-val">{store.roleCustom[activeRole]}</code>
|
||||
<button class="chip" onclick={() => (store.roleCustom = { ...store.roleCustom, [activeRole]: '' })}>↺ usar preset</button>
|
||||
{:else}
|
||||
<span class="override-derived">o elige un preset ↓ (<code>{store.roles[activeRole]}</code>)</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RAW colour space — L / C / H. This is ALL of colour; no presets, no moods. -->
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">{ROLE_LABELS[activeRole]} · color en crudo (OKLCH)</span>
|
||||
<div class="oklch-row">
|
||||
<span class="oklch-ch">L</span>
|
||||
<input class="slider" type="range" min="0" max="1" step="0.005" value={oklch[0]} oninput={(e) => setChannel(0, +e.currentTarget.value)} />
|
||||
<span class="ctl-val">{oklch[0].toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="oklch-row">
|
||||
<span class="oklch-ch">C</span>
|
||||
<input class="slider" type="range" min="0" max="0.37" step="0.005" value={oklch[1]} oninput={(e) => setChannel(1, +e.currentTarget.value)} />
|
||||
<span class="ctl-val">{oklch[1].toFixed(3)}</span>
|
||||
</div>
|
||||
<div class="oklch-row">
|
||||
<span class="oklch-ch">H</span>
|
||||
<input class="slider" type="range" min="0" max="360" step="1" value={oklch[2]} oninput={(e) => setChannel(2, +e.currentTarget.value)} />
|
||||
<span class="ctl-val">{Math.round(oklch[2])}°</span>
|
||||
</div>
|
||||
<p class="ctl-hint">Luminosidad · Chroma (saturación, hasta P3) · Tono. Sube C para vibrante/neón, bájalo para apagado; sube L para claro/pastel. Cualquier color cabe aquí.</p>
|
||||
</div>
|
||||
|
||||
<!-- ONE preset grid for the active role (picking one clears the custom) -->
|
||||
<SwatchGrid
|
||||
value={store.roleCustom[activeRole] ? '' : store.roles[activeRole]}
|
||||
scales={activeRole === 'neutral' ? GRAY_SCALES : undefined}
|
||||
onSelect={(name) => {
|
||||
store.roles = { ...store.roles, [activeRole]: name }
|
||||
store.roleCustom = { ...store.roleCustom, [activeRole]: '' }
|
||||
}}
|
||||
/>
|
||||
|
||||
{@render intents()}
|
||||
{:else}
|
||||
<p class="panel-hint">La semilla pasa por el motor <code>uix.color</code> (<code>deriveScheme</code>, estilo Material-3): genera los 4 roles armonizados + los 5 intents (mantienen su hue, atemperados). Puedes <strong>fijar</strong> cualquier rol abajo.</p>
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">semilla de marca</span><span class="ctl-val">{store.seed}</span></div>
|
||||
<input class="color-input" type="color" bind:value={store.seed} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">variante</span>
|
||||
<div class="seg">
|
||||
{#each ['tonal', 'vibrant', 'monochrome'] as const as v}
|
||||
<button class="seg-btn" data-active={store.variant === v} onclick={() => (store.variant = v)}>{v}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">temper · cohesión de intents</span><span class="ctl-val">{store.temper.toFixed(2)}</span></div>
|
||||
<input class="slider" type="range" min="0" max="1" step="0.02" bind:value={store.temper} />
|
||||
</div>
|
||||
|
||||
<!-- Override individual roles even while deriving (the engine's `overrides`) -->
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">fijar roles · opcional (override)</span>
|
||||
<p class="ctl-hint">Cada rol deriva de la semilla; fija uno para forzar su color.</p>
|
||||
{#each ASSIGNABLE_HIERARCHY as role}
|
||||
<div class="override-row">
|
||||
<input
|
||||
class="color-input sm"
|
||||
type="color"
|
||||
value={store.roleOverrides[role] || '#7c7c8a'}
|
||||
oninput={(e) => (store.roleOverrides = { ...store.roleOverrides, [role]: e.currentTarget.value })}
|
||||
/>
|
||||
<span class="override-name">{ROLE_LABELS[role]}</span>
|
||||
{#if store.roleOverrides[role]}
|
||||
<code class="override-val">{store.roleOverrides[role]}</code>
|
||||
<button class="chip" onclick={() => (store.roleOverrides = { ...store.roleOverrides, [role]: '' })}>↺ derivar</button>
|
||||
{:else}
|
||||
<span class="override-derived">derivado</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="ctl-hint">Superficies, texto y bordes (chrome neutro) se reproyectan automáticamente desde el rol <strong>neutral</strong>.</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.role-chip { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||
.role-dot { inline-size: 0.85rem; block-size: 0.85rem; border-radius: 4px; box-shadow: inset 0 0 0 1px #ffffff22; flex: none; }
|
||||
.intent-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.intent-name { font-size: 0.76rem; inline-size: 4rem; }
|
||||
.intent-select { flex: 1; }
|
||||
.override-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.override-name { font-size: 0.76rem; inline-size: 5.5rem; }
|
||||
.override-val { font-size: 0.66rem; color: #a5b4fc; }
|
||||
.override-derived { font-size: 0.68rem; color: #7a7a92; }
|
||||
.color-input.sm { inline-size: 1.7rem; block-size: 1.7rem; }
|
||||
|
||||
.oklch-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.oklch-ch { font-family: monospace; font-size: 0.72rem; color: #b6b6c8; inline-size: 1rem; text-align: center; }
|
||||
.oklch-row .ctl-val { inline-size: 3rem; text-align: end; }
|
||||
|
||||
.fam-grid { display: grid; grid-template-columns: repeat(11, 1fr); gap: 3px; }
|
||||
.fam-sw { block-size: 1.35rem; border-radius: 5px; border: 1px solid #ffffff14; cursor: pointer; padding: 0; }
|
||||
.fam-sw[data-active='true'] { box-shadow: 0 0 0 2px #0f0f18, 0 0 0 3px #fff; }
|
||||
|
||||
.palette-view { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.6rem; background: #0f0f18; border: 1px solid #26263a; border-radius: 10px; }
|
||||
.pal-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.pal-row.isrole .pal-name { color: #e7e7f0; }
|
||||
.pal-name { font-family: monospace; font-size: 0.6rem; color: #9a9ab0; inline-size: 5.2rem; text-align: end; display: flex; gap: 0.25rem; align-items: center; justify-content: flex-end; }
|
||||
.rtag { font-style: normal; font-size: 0.55rem; font-weight: 600; background: #ffffff1e; color: #cdd; border-radius: 3px; padding: 0 0.22rem; }
|
||||
.pal-steps { display: flex; flex: 1; border-radius: 4px; overflow: hidden; }
|
||||
.pal-step { flex: 1; block-size: 1.1rem; }
|
||||
.pal-step.solid { box-shadow: inset 0 0 0 2px #ffffff, inset 0 0 0 3px #00000055; position: relative; z-index: 1; }
|
||||
</style>
|
||||
@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import type { ThemeDraftStore } from '../_lib/store.svelte'
|
||||
|
||||
let { store }: { store: ThemeDraftStore } = $props()
|
||||
|
||||
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss']
|
||||
|
||||
function toggleAuroraRole(role: string): void {
|
||||
store.auroraRoles = store.auroraRoles.includes(role)
|
||||
? store.auroraRoles.filter((r) => r !== role)
|
||||
: [...store.auroraRoles, role]
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<h2 class="panel-title">Gradientes</h2>
|
||||
<p class="panel-hint">Derivados de <strong>roles de color</strong> en OKLCH — re-tiñen con la paleta y flipean light/dark. Lo que ningún referente hace. Se usan como fondo (activa <em>fondos: degradado</em> en Superficies).</p>
|
||||
|
||||
<!-- ── Aurora mesh (the cristal stage) ── -->
|
||||
<div class="ctl">
|
||||
<div class="ctl-head">
|
||||
<span class="ctl-label">aurora · malla de roles → --gradient-aurora</span>
|
||||
<button class="chip" data-active={store.auroraOn} onclick={() => (store.auroraOn = !store.auroraOn)}>{store.auroraOn ? 'on' : 'off'}</button>
|
||||
</div>
|
||||
<p class="ctl-hint">Blobs radiales por rol sobre la superficie (el escenario de cristal).</p>
|
||||
<div class="chips">
|
||||
{#each ROLES as role}
|
||||
<button class="chip" data-active={store.auroraRoles.includes(role)} onclick={() => toggleAuroraRole(role)}>{role}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<div class="ctl-head"><span class="ctl-label">aurora · alpha</span><span class="ctl-val">{store.auroraAlpha.toFixed(2)}</span></div>
|
||||
<input class="slider" type="range" min="0.2" max="1" step="0.05" bind:value={store.auroraAlpha} />
|
||||
</div>
|
||||
|
||||
<!-- ── Brand sweep ── -->
|
||||
<div class="ctl">
|
||||
<div class="ctl-head">
|
||||
<span class="ctl-label">marca · barrido de rol → --gradient-brand</span>
|
||||
<button class="chip" data-active={store.brandOn} onclick={() => (store.brandOn = !store.brandOn)}>{store.brandOn ? 'on' : 'off'}</button>
|
||||
</div>
|
||||
<div class="seg">
|
||||
{#each ['deepen', 'sheen', 'halo'] as const as f}
|
||||
<button class="seg-btn" data-active={store.brandFactory === f} onclick={() => (store.brandFactory = f)}>{f}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="chips">
|
||||
{#each ['primary', 'secondary', 'tertiary', 'affirm', 'threat', 'loss'] as role}
|
||||
<button class="chip" data-active={store.brandRole === role} onclick={() => (store.brandRole = role)}>{role}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="ctl-hint"><code>deepen</code> = rampa 9→11 · <code>sheen</code> = barrido highlight · <code>halo</code> = glow radial.</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Palette generator — SEE `generatePalette`: the fixed hue names regenerated from
|
||||
* one recipe at a chosen character. Pure client-side, no eidos runtime; swatches are
|
||||
* hex (gamut-mapped), so it paints everywhere.
|
||||
*/
|
||||
import { parseColor, scaleToTemplate, hueDistance } from '$color'
|
||||
import { COLOR_SCALE_STEPS } from '$uix/eidos/lib/config-types'
|
||||
import { THEME_BASE_LIGHT_COLOR_SCALES, THEME_BASE_DARK_COLOR_SCALES } from '$uix/eidos/lib/themes/base'
|
||||
import { generatePalette, type HueAnchor } from '$uix/eidos/lib/generate-palette'
|
||||
|
||||
const scalesL = THEME_BASE_LIGHT_COLOR_SCALES
|
||||
const names = Object.keys(scalesL)
|
||||
// The FIXED hue names + their NATIVE hue & chroma (parsed from the shipped step 9).
|
||||
// Native chroma keeps browns/olives/metals muted — a uniform chroma would turn a
|
||||
// brown into a vivid orange.
|
||||
// steel (the 32nd) now ships in the base palette, so it comes from `names`.
|
||||
const anchors: HueAnchor[] = names.map((n) => {
|
||||
const [, c, h] = parseColor(scalesL[n]['9'])
|
||||
return { name: n, hue: h, chroma: c }
|
||||
})
|
||||
const grayNames = names.filter((n) => parseColor(scalesL[n]['9'])[1] < 0.03)
|
||||
// Donor SET (not one template): each family morphs from its nearest-hue donor, so
|
||||
// bright hues (yellow/amber/lime) inherit a bright-9 L-curve and never land muddy.
|
||||
const chromatic = names.filter((n) => parseColor(scalesL[n]['9'])[1] > 0.04)
|
||||
const tpl = (scales: typeof scalesL, n: string) =>
|
||||
scaleToTemplate(COLOR_SCALE_STEPS.map((s) => scales[n][s]))
|
||||
const donorsL = Object.fromEntries(chromatic.map((n) => [n, tpl(scalesL, n)]))
|
||||
const donorsD = Object.fromEntries(chromatic.map((n) => [n, tpl(THEME_BASE_DARK_COLOR_SCALES, n)]))
|
||||
|
||||
let vivacity = $state(1)
|
||||
let tone = $state(0)
|
||||
let neutralHue = $state(55)
|
||||
let mode = $state<'light' | 'dark'>('light')
|
||||
|
||||
const palette = $derived(
|
||||
generatePalette(anchors, mode === 'dark' ? donorsD : donorsL, {
|
||||
vivacity,
|
||||
toneShift: tone,
|
||||
neutralHue,
|
||||
neutralNames: grayNames
|
||||
})
|
||||
)
|
||||
const vivLabel = $derived(
|
||||
vivacity < 0.6 ? 'pastel' : vivacity < 0.9 ? 'suave' : vivacity < 1.25 ? 'normal' : vivacity < 1.55 ? 'vibrante' : 'neón'
|
||||
)
|
||||
|
||||
// ── Role harmony: roles are a SELECTION from the palette by hue relationship ──
|
||||
const ROLE_SLOTS = ['primary', 'secondary', 'tertiary'] as const
|
||||
const HARMONIES = {
|
||||
'análogo': [0, 30, -30],
|
||||
'tríada': [0, 120, 240],
|
||||
'complementario': [0, 180, 90],
|
||||
'material +60': [0, 30, 60]
|
||||
} satisfies Record<string, number[]>
|
||||
const norm = (h: number) => ((h % 360) + 360) % 360
|
||||
const chromaticAnchors = anchors.filter((a) => !grayNames.includes(a.name))
|
||||
|
||||
let baseHue = $state(Math.round(anchors.find((a) => a.name === 'blue')?.hue ?? 260))
|
||||
let harmony = $state<keyof typeof HARMONIES>('análogo')
|
||||
|
||||
const roles = $derived(
|
||||
HARMONIES[harmony].map((off, i) => {
|
||||
const h = norm(baseHue + off)
|
||||
const fam = chromaticAnchors.reduce((b, a) =>
|
||||
hueDistance(a.hue, h) < hueDistance(b.hue, h) ? a : b
|
||||
)
|
||||
const hex = palette.find((p) => p.name === fam.name)?.hex[8] ?? '#888'
|
||||
return { slot: ROLE_SLOTS[i], targetHue: Math.round(h), family: fam.name, hex }
|
||||
})
|
||||
)
|
||||
const roleByFamily = $derived(new Map(roles.map((r) => [r.family, r.slot])))
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Paleta · generador</title></svelte:head>
|
||||
|
||||
<div class="root" data-mode={mode}>
|
||||
<header>
|
||||
<h1>Paleta generada — una receta, {palette.length} rotaciones</h1>
|
||||
<p>Los nombres (hue) son fijos; el <strong>carácter</strong> regenera todas las escalas. `generatePalette` sobre <code>$color</code>, cero deps.</p>
|
||||
</header>
|
||||
|
||||
<div class="controls">
|
||||
<div class="ctl">
|
||||
<label>vivacidad · {vivacity.toFixed(2)} <em>{vivLabel}</em></label>
|
||||
<input type="range" min="0.3" max="1.9" step="0.05" bind:value={vivacity} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<label>tono (L) · {tone >= 0 ? '+' : ''}{tone.toFixed(2)}</label>
|
||||
<input type="range" min="-0.15" max="0.15" step="0.01" bind:value={tone} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<label>neutral · matiz {neutralHue}°</label>
|
||||
<input type="range" min="0" max="360" step="1" bind:value={neutralHue} />
|
||||
</div>
|
||||
<div class="seg">
|
||||
<button data-on={mode === 'light'} onclick={() => (mode = 'light')}>◐ claro</button>
|
||||
<button data-on={mode === 'dark'} onclick={() => (mode = 'dark')}>◑ oscuro</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roles">
|
||||
<div class="ctl">
|
||||
<label>rol base (hue) · {baseHue}°</label>
|
||||
<input type="range" min="0" max="360" step="1" bind:value={baseHue} />
|
||||
</div>
|
||||
<div class="seg">
|
||||
{#each Object.keys(HARMONIES) as h}
|
||||
<button data-on={harmony === h} onclick={() => (harmony = h as keyof typeof HARMONIES)}>{h}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="rolechips">
|
||||
{#each roles as r}
|
||||
<div class="rolechip">
|
||||
<span class="swatch" style="background: {r.hex}"></span>
|
||||
<span class="rc-slot">{r.slot}</span>
|
||||
<span class="rc-fam">{r.family} · {r.targetHue}°</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="palette">
|
||||
{#each palette as scale}
|
||||
<div class="scale" class:isrole={roleByFamily.has(scale.name)}>
|
||||
<span class="name">
|
||||
{#if roleByFamily.has(scale.name)}<em class="tag">{roleByFamily.get(scale.name)}</em>{/if}
|
||||
{scale.name}
|
||||
</span>
|
||||
<div class="steps">
|
||||
{#each scale.hex as hex, i}
|
||||
<span class="step" class:solid={i === 8} style="background: {hex}" title="{scale.name}-{i + 1}: {hex}"></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { min-block-size: 100vh; padding: clamp(1.5rem, 4vw, 3rem); font-family: system-ui, sans-serif; }
|
||||
.root[data-mode='light'] { background: #fafafa; color: #1a1a1a; }
|
||||
.root[data-mode='dark'] { background: #0c0c12; color: #e7e7ef; }
|
||||
header { max-inline-size: 60rem; margin-block-end: 2rem; }
|
||||
h1 { font-size: clamp(1.4rem, 3vw, 2rem); margin: 0 0 0.5rem; }
|
||||
header p { margin: 0; opacity: 0.7; font-size: 0.95rem; }
|
||||
code, strong { font-weight: 600; }
|
||||
|
||||
.controls { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: end; margin-block-end: 2rem; padding: 1rem 1.25rem; border-radius: 12px; background: color-mix(in srgb, currentColor 6%, transparent); }
|
||||
.ctl { display: flex; flex-direction: column; gap: 0.35rem; min-inline-size: 12rem; }
|
||||
.ctl label { font-size: 0.78rem; font-family: monospace; opacity: 0.8; }
|
||||
.ctl em { font-style: normal; opacity: 0.6; }
|
||||
.ctl input[type='range'] { accent-color: #6366f1; }
|
||||
.seg { display: flex; gap: 0.25rem; }
|
||||
.seg button { font: inherit; font-size: 0.8rem; padding: 0.4rem 0.7rem; border-radius: 8px; cursor: pointer; border: 1px solid color-mix(in srgb, currentColor 20%, transparent); background: transparent; color: inherit; }
|
||||
.seg button[data-on='true'] { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||
|
||||
.roles { display: flex; flex-wrap: wrap; gap: 1.25rem; align-items: end; margin-block-end: 1.5rem; padding: 1rem 1.25rem; border-radius: 12px; background: color-mix(in srgb, currentColor 6%, transparent); }
|
||||
.rolechips { display: flex; gap: 0.6rem; flex-wrap: wrap; }
|
||||
.rolechip { display: flex; align-items: center; gap: 0.45rem; padding: 0.3rem 0.6rem 0.3rem 0.35rem; border-radius: 999px; background: color-mix(in srgb, currentColor 8%, transparent); }
|
||||
.rolechip .swatch { inline-size: 1.1rem; block-size: 1.1rem; border-radius: 50%; box-shadow: inset 0 0 0 1px #0003; }
|
||||
.rc-slot { font: 600 0.72rem monospace; }
|
||||
.rc-fam { font-size: 0.72rem; opacity: 0.65; font-family: monospace; }
|
||||
|
||||
.palette { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.scale { display: flex; align-items: center; gap: 0.8rem; }
|
||||
.scale.isrole .name { opacity: 1; font-weight: 600; }
|
||||
.name { inline-size: 7.5rem; font-family: monospace; font-size: 0.8rem; text-align: end; opacity: 0.85; display: flex; gap: 0.35rem; align-items: center; justify-content: flex-end; }
|
||||
.tag { font-style: normal; font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.02em; padding: 0.05rem 0.3rem; border-radius: 4px; background: color-mix(in srgb, currentColor 15%, transparent); opacity: 0.9; }
|
||||
.steps { display: flex; flex: 1; max-inline-size: 46rem; border-radius: 6px; overflow: hidden; box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 12%, transparent); }
|
||||
.step { flex: 1; block-size: 2.2rem; }
|
||||
.step.solid { position: relative; z-index: 1; box-shadow: inset 0 0 0 2px #fff, inset 0 0 0 3px #0006; }
|
||||
</style>
|
||||
@ -0,0 +1,2 @@
|
||||
// Palette generator — pure, client-only. See generatePalette.
|
||||
export const prerender = false
|
||||
Loading…
Reference in new issue