Color schemes, tones and customization
Choose a source and scheme deliberately, inspect realized HCT and role relationships, and verify scoped changes before adopting them.
Source, palette, role and application
Generation does not decide which task is important or establish review authority. Record source, algorithm/version, scheme choices, role mappings and the actual surfaces/states that were verified. Keep a captured scheme distinguishable from a draft setting and a deployed product theme.
| Layer | Responsibility | Failure |
|---|---|---|
| Source color | A deliberate input to generation | Treating a wallpaper or photograph as automatically permitted product identity |
| Key colors | Starting colors for related palette families | Assuming every family reproduces the original seed |
| Tonal palettes | A range of realizable colors at chosen tones | Calling an HSL lightness ramp an HCT palette |
| Semantic roles | Purpose, pairings, theme and contrast relationships | Using one attractive swatch for every foreground/state |
| Application | Map the agreed roles to actual components and surfaces | Declaring a generated palette a complete accessible theme |
Hue, chroma, tone and gamut
HCT combines hue, chroma and perceptual tone to support related colors and contrast-aware roles. Tone is based on CIE lightness; it is not interchangeable with the lightness slider in HSL. A request for high chroma at a very light or dark tone may exceed the available sRGB gamut.
Report what was requested and what the algorithm actually produced. Hue can wrap around its circular domain, and near-neutral colors do not preserve the same chromatic identity as a saturated request. Keep the realized hex/HCT values alongside the request instead of silently calling both exact.
| Coordinate | Tool contract | Inspection |
|---|---|---|
| Hue | 0–360° request | Compare realized hue and circular wrap |
| Chroma | 0–150 bounded experiment | Inspect actual gamut-limited chroma |
| Tone | 0–100 request | Inspect realized lightness and pair contrast |
| Output | Opaque sRGB #RRGGBB | No alpha, image composition or wide-gamut claim |
import {Hct, hexFromArgb} from "@material/material-color-utilities";
const requested = {hue: 49, chroma: 80, tone: 95};
const realized = Hct.from(requested.hue, requested.chroma, requested.tone);
const hex = hexFromArgb(realized.toInt());
// Inspect realized.hue/chroma/tone; requested chroma may not be achieved.Static, brand and dynamic scheme selection
The shared MobTranslate theme is an authored Earth palette, not a generated Material baseline. This explorer produces local proposals without replacing those tokens. A manual hex/HCT choice is an explicit source input; the browser cannot automatically read a person’s wallpaper through this tool.
Use stable source selection and fallbacks when content changes or a platform cannot provide a color. A source-derived accent should not change permission, error or review meaning. Content attribution and cultural use permissions remain separate from pixel selection.
| Choice | Use when | Review |
|---|---|---|
| Static baseline | A stable authored scheme fits the product and tasks | Verify role/state pairs in both themes |
| Custom brand | An established identity needs deliberate palette/role choices | Keep legibility and role relationships ahead of exact brand shade reproduction |
| User-generated source | People explicitly choose personalization or a supported platform supplies it | Keep preference ownership, fallback and task meaning clear |
| Content-based source | Relevant, permitted content can guide a contextual accent | Verify the actual pixels, selection algorithm and scope |
| Multiple sources/schemes | Different contextual regions need a documented relationship | Keep coherent role pairs inside each scope and avoid conflicting primary emphasis |
Working source-to-role explorer
Capture a proposal and inspect the SDK’s role collection, declared backgrounds/contrast targets, tonal key colors, sampled tones and full JSON. Draft edits do not rewrite the captured output. Requested/effective specification versions expose SDK fallback rather than presenting unsupported variant parity.
Tonal spot, Expressive and Fidelity are actual SDK constructors. This tool pins Material Color Utilities 0.4.0, requests the selected 2021/2025 specification and uses its phone algorithm parameter. Those choices are captured; they are not device detection, OS personalization or native-theme installation.
The original SVG fixture has four equal-area bands. Extraction decodes its actual image, quantizes pixels and asks the SDK to rank a suitable seed. It does not merely choose the largest area, and it does not classify the image’s cultural meaning. The result populates the draft only.
Generate a captured proposal with Material Color Utilities 0.4.0. The algorithm’s platform parameter is phone; it does not detect your device or read a wallpaper. Preview theme is independent of this page’s light/dark theme.
Original vector fixture: 64×64 pixels. Band specification: 16×64 #b45e2a; 16×64 #386b4f; 16×64 #395d75; 16×64 #faf8f5. It is not a source photograph or cultural asset.
Hex colors must use #RRGGBB. Requested hue is 0–360°, chroma 0–150 and tone 0–100. The sRGB gamut can reduce realized chroma; HCT is not HSL. Custom-color harmonization does not rewrite the generated semantic roles.
Draft settings are valid; capture to apply them to the local proposal.
Initial proposal. Shared Earth tokens are unchanged.
- Captured variant/theme/contrast
- tonal; light; 0
- Requested → effective specification
- 2025 → 2025
- Realized source
- #b45e2a
- Realized hue/chroma/tone
- 46.817 / 47.387 / 49.345
- Captured custom color
- #386b4f → #386b4f; harmonization disabled
- Available role values
- 49, derived from the SDK’s current allColors collection
SDK fallback is explicit. Some variants resolve a requested 2025 scheme through 2021 rules. A specification/variant name is not a conformance or contrast certificate.
Scoped action specimen
Only this surface and action use the captured role pairs. Hover/pressed keep the same fill here; a full state system needs additional design and verification.
primary: #895131; realized tone 40.287
SDK background classification: background. SDK contrast-curve target: 4.5.
- surface_dim (#ecd5cb): 4.550:1. AA normal-text threshold: passes; 3:1 comparison: passes.
| Role | Hex | Declared backgrounds and ratios |
|---|---|---|
| background | #fff8f6 | No declared pairing |
| on_background | #3d2f28 | surface_dim: 9.138:1 |
| surface | #fff8f6 | No declared pairing |
| surface_dim | #ecd5cb | No declared pairing |
| surface_bright | #fff8f6 | No declared pairing |
| surface_container_lowest | #ffffff | No declared pairing |
| surface_container_low | #fff1eb | No declared pairing |
| surface_container | #feeae1 | No declared pairing |
| surface_container_high | #f9e4db | No declared pairing |
| surface_container_highest | #f5ded3 | No declared pairing |
| on_surface | #3d2f28 | surface_dim: 9.138:1 |
| on_surface_variant | #6c5c53 | surface_dim: 4.538:1 |
| outline | #89776e | surface_dim: 3.033:1 |
| outline_variant | #c3aea4 | surface_dim: 1.508:1 |
| inverse_surface | #130d0a | No declared pairing |
| inverse_on_surface | #a79a95 | inverse_surface: 7.066:1 |
| primary | #895131 | surface_dim: 4.550:1 |
| primary_dim | #7b4526 | surface_container_high: 6.301:1 |
| on_primary | #fff7f5 | primary: 6.050:1 |
| primary_container | #fab089 | surface_dim: 1.284:1 |
| on_primary_container | #602f12 | primary_container: 6.084:1 |
| primary_fixed | #fab089 | surface_dim: 1.284:1 |
| primary_fixed_dim | #eba37d | No declared pairing |
| on_primary_fixed | #461b01 | primary_fixed_dim: 7.102:1 |
| on_primary_fixed_variant | #6b381a | primary_fixed_dim: 4.564:1 |
| inverse_primary | #fab089 | inverse_surface: 10.679:1 |
| secondary | #775849 | surface_dim: 4.562:1 |
| secondary_dim | #6a4d3e | surface_container_high: 6.249:1 |
| on_secondary | #fff7f5 | secondary: 6.066:1 |
| secondary_container | #ffdbca | surface_dim: 1.086:1 |
| on_secondary_container | #684b3c | secondary_container: 6.104:1 |
| secondary_fixed | #ffdbca | surface_dim: 1.086:1 |
| secondary_fixed_dim | #f5ccb8 | No declared pairing |
| on_secondary_fixed | #54392b | secondary_fixed_dim: 7.116:1 |
| on_secondary_fixed_variant | #735545 | secondary_fixed_dim: 4.563:1 |
| tertiary | #6e5e26 | surface_dim: 4.540:1 |
| tertiary_dim | #61521b | surface_container_high: 6.279:1 |
| on_tertiary | #fff8ed | tertiary: 6.045:1 |
| tertiary_container | #fee7a1 | surface_dim: 1.148:1 |
| on_tertiary_container | #63541d | tertiary_container: 6.093:1 |
| tertiary_fixed | #fee7a1 | surface_dim: 1.148:1 |
| tertiary_fixed_dim | #efd994 | No declared pairing |
| on_tertiary_fixed | #50420b | tertiary_fixed_dim: 7.069:1 |
| on_tertiary_fixed_variant | #6e5e26 | tertiary_fixed_dim: 4.563:1 |
| error | #a73b21 | surface_dim: 4.542:1 |
| error_dim | #791903 | surface_container_high: 8.779:1 |
| on_error | #fff7f6 | error: 6.044:1 |
| error_container | #fd795a | surface_dim: 1.855:1 |
| on_error_container | #6e1400 | error_container: 4.571:1 |
Palette key color: #a66846; realized HCT 46.678 / 32.232 / 50.037.
| Requested tone | Realized hex/tone/chroma | Swatch |
|---|---|---|
| 0 | #000000; 0.000; 0.000 | |
| 10 | #331100; 9.835; 27.844 | |
| 20 | #512407; 20.149; 32.006 | |
| 30 | #6c391b; 29.896; 32.016 | |
| 40 | #895030; 40.030; 32.259 | |
| 50 | #a66846; 50.037; 32.232 | |
| 60 | #c3815d; 59.973; 31.962 | |
| 70 | #e19b75; 69.990; 31.840 | |
| 80 | #ffb690; 80.027; 31.122 | |
| 90 | #ffdbca; 89.946; 13.691 | |
| 100 | #ffffff; 100.000; 2.869 |
Full captured request, roles, palettes and custom color
Preparing the full-precision captured output.
Role namespace CSS export
/* Material role namespace only; not a complete MobTranslate theme. */
.generated-color-proposal {
--proposal-background: #fff8f6;
--proposal-on-background: #3d2f28;
--proposal-surface: #fff8f6;
--proposal-surface-dim: #ecd5cb;
--proposal-surface-bright: #fff8f6;
--proposal-surface-container-lowest: #ffffff;
--proposal-surface-container-low: #fff1eb;
--proposal-surface-container: #feeae1;
--proposal-surface-container-high: #f9e4db;
--proposal-surface-container-highest: #f5ded3;
--proposal-on-surface: #3d2f28;
--proposal-on-surface-variant: #6c5c53;
--proposal-outline: #89776e;
--proposal-outline-variant: #c3aea4;
--proposal-inverse-surface: #130d0a;
--proposal-inverse-on-surface: #a79a95;
--proposal-primary: #895131;
--proposal-primary-dim: #7b4526;
--proposal-on-primary: #fff7f5;
--proposal-primary-container: #fab089;
--proposal-on-primary-container: #602f12;
--proposal-primary-fixed: #fab089;
--proposal-primary-fixed-dim: #eba37d;
--proposal-on-primary-fixed: #461b01;
--proposal-on-primary-fixed-variant: #6b381a;
--proposal-inverse-primary: #fab089;
--proposal-secondary: #775849;
--proposal-secondary-dim: #6a4d3e;
--proposal-on-secondary: #fff7f5;
--proposal-secondary-container: #ffdbca;
--proposal-on-secondary-container: #684b3c;
--proposal-secondary-fixed: #ffdbca;
--proposal-secondary-fixed-dim: #f5ccb8;
--proposal-on-secondary-fixed: #54392b;
--proposal-on-secondary-fixed-variant: #735545;
--proposal-tertiary: #6e5e26;
--proposal-tertiary-dim: #61521b;
--proposal-on-tertiary: #fff8ed;
--proposal-tertiary-container: #fee7a1;
--proposal-on-tertiary-container: #63541d;
--proposal-tertiary-fixed: #fee7a1;
--proposal-tertiary-fixed-dim: #efd994;
--proposal-on-tertiary-fixed: #50420b;
--proposal-on-tertiary-fixed-variant: #6e5e26;
--proposal-error: #a73b21;
--proposal-error-dim: #791903;
--proposal-on-error: #fff7f6;
--proposal-error-container: #fd795a;
--proposal-on-error-container: #6e1400;
}All measurements use opaque sRGB role values. They do not cover overlays, every interaction state, color perception or cultural authority. Extraction reads only the original local fixture; wallpaper, browser personal color and remote source records are not read.
Role families and pairing
These families are guidance and generated data, not a claim that every Material role already exists as a MobTranslate token. The explorer’s allColors collection is read from the pinned SDK; background associations are derived from each role’s declarations instead of a hand-maintained synonym/pairing dictionary. Palettes also expose their actual key colors.
| Material role family | Purpose | Local treatment |
|---|---|---|
| Primary / on-primary | Main accent and readable content on its fill | Explorer action specimen maps this pair locally |
| Secondary / tertiary | Supporting accents with their own paired content | Available in generated roles/palettes; no automatic mapping to a source status |
| Containers / on-container | Less dominant accent surfaces | Keep the matching pair and contained task relationship |
| Error / on-error | Error emphasis and readable content | A generated error color does not supply an error message |
| Surface / on-surface | Ordinary reading and component surfaces | Explorer uses this opaque pair for its scoped specimen |
| Surface containers | Low through high containment layers | Tone-based differentiation is separate from shadow/stacking |
| Bright / dim surfaces | Surface adaptations for the chosen scheme | Inspect actual theme/contrast output |
| Inverse surface/content/primary | Contrasting contextual treatment | Verify its local background and duration |
| Outline / outline variant | Required or supporting boundaries | Do not treat every outline variant as a required 3:1 control boundary |
| Fixed accents / on-fixed variants | Accent treatments with defined cross-theme relationships | Inspect the SDK’s actual fixed/dim backgrounds and paired foregrounds |
| Shadow / scrim / tint | Layering and treatment inputs | Opaque role values alone do not prove a composited overlay’s contrast |
Contrast levels and honest measurements
Contrast level is an algorithm input, not a WCAG certificate. Standard, medium and high can change tone assignments while preserving relationships. The SDK also allows lower-contrast experimentation; it may produce pairs unsuitable for normal text.
Measure realized opaque role values and distinguish the SDK’s curve target from the criterion that applies to the final use. Normal-text AA uses 4.5:1; large text and required non-text information have their own definitions and contexts. A displayed rounded ratio must not be rounded into a pass.
- The explorer checks both declared backgrounds when a role supplies them. An absent relationship remains absent.
- The opaque ratio uses realized lightness/relative luminance through the SDK’s contrast utility, with black/white and reference-pair tests.
- Unsafe normal-text preview pairs suppress the specimen while preserving role values in the neutral interface.
- Images, gradients, opacity, overlays and state changes need their actual composed background assessed.
- Keep color-independent names and state information, including failure/recovery messages.
Combine and remap colors deliberately
Keep each contextual scheme in an explicit scope. A brand header and content-derived panel can coexist when their primary emphasis, surfaces, text and focus treatment have coherent boundaries. A portalled surface needs the intended values in its own rendered scope; inheritance from an unrelated trigger is not sufficient.
Remap a role because the element’s purpose warrants it, then inspect its foreground, surrounding surface, state and adjacent controls. Do not choose a different token for every isolated screenshot. Retain an auditable mapping from generated roles to the library/application contract.
The specimen maps only primary/foreground plus an opaque surface and focus treatment. Hover/pressed deliberately retain the same fill. Its CSS export uses a separate proposal namespace; it is not a complete production theme or an automatic migration.
| Operation | Required evidence |
|---|---|
| Local proposal | Actual computed surface/action colors and unchanged root tokens |
| Semantic remap | Purpose, old/new role and every affected pair/state |
| Mixed schemes | Defined regions and readable boundaries/focus |
| Portal | Correct container/theme scope and dismissal/focus behavior |
| Adoption | Reviewed mapping, compatibility and verification before replacing shared tokens |
New static colors and custom roles
Define a custom color when a real product purpose cannot be represented by the existing contract. Name that purpose and its background/foreground, theme behavior, contrast and state responsibilities. A palette sample or culturally significant source color does not automatically become an interface role.
Keep custom-role definition separate from creating a new baseline or dynamic scheme. Record which algorithm or reviewed fixed values supply it, and test all actual contexts. Do not create a color-only vocabulary for source evidence or permission status.
Baseline adjustment, fidelity and harmonization
The custom-color control implements an explicit SDK harmonization experiment and preserves original/result coordinates. It does not rewrite generated semantic roles, grant rights to use an asset or guarantee contrast. Keep recognizable error/status meaning rather than harmonizing everything for decoration.
| Adjustment | Meaning | Verify |
|---|---|---|
| Own baseline | A reviewed stable set of role assignments | Light/dark/state compatibility and actual consumer output |
| Own dynamic scheme | A deliberate algorithm/palette/role policy | Versions, source inputs, fallback and contrast across representative seeds |
| Fidelity | A scheme choice intended to retain source character in relevant roles | Inspect actual outputs and effective spec; it is not exact seed reproduction everywhere |
| Harmonization | Blend a custom color’s hue relationship toward a source | Original/result values, role meaning and independent contrast |
| Direct role override | Replace an agreed role value for a reason | Every associated foreground, surface, focus and state |
Design-to-development handoff
- Record the source rationale, original bytes/provenance where applicable, algorithm version, requested/effective scheme and contrast settings.
- Share complete role definitions and mappings, rather than screenshots or a few hex values alone.
- Use named semantic pairs in the design file and the actual consuming components; inspect the rendered result after export/import.
- Make manual/static decisions distinguishable from platform-personalized and content-derived values.
- Specify a fallback for unavailable sources and a strategy for updating/changing content without unexpected task-state changes.
- Keep a reviewed migration boundary and verify the consumers before changing shared Earth tokens.
Compiled generation helper
import {
argbFromHex,
hexFromArgb,
Hct,
TonalPalette,
Blend,
Contrast,
SchemeTonalSpot,
SchemeExpressive,
SchemeFidelity,
type DynamicColor,
} from '@material/material-color-utilities';
const constructors = {
tonal: SchemeTonalSpot,
expressive: SchemeExpressive,
fidelity: SchemeFidelity,
};
export type ColorVariant = keyof typeof constructors;
export interface ColorProposalInput {
sourceMode: 'hex' | 'hct';
sourceHex: string;
hue: number;
chroma: number;
tone: number;
variant: ColorVariant;
specVersion: '2021' | '2025';
isDark: boolean;
contrastLevel: number;
customHex: string;
harmonize: boolean;
}
export const initialColorInput: ColorProposalInput = {
sourceMode: 'hex',
sourceHex: '#b45e2a',
hue: 49,
chroma: 45,
tone: 50,
variant: 'tonal',
specVersion: '2025',
isDark: false,
contrastLevel: 0,
customHex: '#386b4f',
harmonize: false,
};
const validHex = (value: string) => /^#[\da-f]{6}$/i.test(value);
export function validateColorInput(input: ColorProposalInput): string[] {
const errors: string[] = [];
if (input.sourceMode !== 'hex' && input.sourceMode !== 'hct')
errors.push('Choose a supported source mode.');
if (input.sourceMode === 'hex' && !validHex(input.sourceHex))
errors.push('Source must be an opaque #RRGGBB color.');
if (input.sourceMode === 'hct') {
if (!Number.isFinite(input.hue) || input.hue < 0 || input.hue > 360)
errors.push('Hue must be 0–360 degrees.');
if (
!Number.isFinite(input.chroma) ||
input.chroma < 0 ||
input.chroma > 150
)
errors.push('Chroma must be 0–150.');
if (!Number.isFinite(input.tone) || input.tone < 0 || input.tone > 100)
errors.push('Tone must be 0–100.');
}
if (!Object.hasOwn(constructors, input.variant))
errors.push('Choose a supported scheme variant.');
if (input.specVersion !== '2021' && input.specVersion !== '2025')
errors.push('Choose a supported specification version.');
if (
!Number.isFinite(input.contrastLevel) ||
input.contrastLevel < -1 ||
input.contrastLevel > 1
)
errors.push('Contrast level must be −1–1.');
if (!validHex(input.customHex))
errors.push('Custom color must be an opaque #RRGGBB color.');
if (typeof input.isDark !== 'boolean' || typeof input.harmonize !== 'boolean')
errors.push('Theme and harmonization choices must be explicit booleans.');
return errors;
}
const coordinates = (argb: number) => {
const color = Hct.fromInt(argb);
return {
hex: hexFromArgb(argb),
hue: color.hue,
chroma: color.chroma,
tone: color.tone,
};
};
export function opaqueColorRatio(first: string, second: string): number {
if (!validHex(first) || !validHex(second))
throw new RangeError('Contrast requires two opaque #RRGGBB colors.');
return Contrast.ratioOfTones(
Hct.fromInt(argbFromHex(first)).tone,
Hct.fromInt(argbFromHex(second)).tone,
);
}
export function buildColorProposal(input: ColorProposalInput) {
const errors = validateColorInput(input);
if (errors.length) throw new RangeError(errors.join(' '));
const source =
input.sourceMode === 'hex'
? Hct.fromInt(argbFromHex(input.sourceHex))
: Hct.from(input.hue, input.chroma, input.tone);
const Scheme = constructors[input.variant],
scheme = new Scheme(
source,
input.isDark,
input.contrastLevel,
input.specVersion,
'phone',
);
const roles = scheme.colors.allColors.map((color) => {
const argb = color.getArgb(scheme),
hex = hexFromArgb(argb);
const backgrounds = [
color.background?.(scheme),
color.secondBackground?.(scheme),
].filter(
(background): background is DynamicColor => background !== undefined,
);
const seen = new Set<string>();
return {
name: color.name,
hex,
tone: Hct.fromInt(argb).tone,
isBackground: color.isBackground,
sdkTarget:
color.contrastCurve?.(scheme)?.get(input.contrastLevel) ?? null,
backgrounds: backgrounds
.filter((background) => {
if (seen.has(background.name)) return false;
seen.add(background.name);
return true;
})
.map((background) => {
const backgroundHex = hexFromArgb(background.getArgb(scheme));
return {
name: background.name,
hex: backgroundHex,
ratio: opaqueColorRatio(hex, backgroundHex),
};
}),
};
});
const palettes = Object.entries({
primary: scheme.primaryPalette,
secondary: scheme.secondaryPalette,
tertiary: scheme.tertiaryPalette,
neutral: scheme.neutralPalette,
neutralVariant: scheme.neutralVariantPalette,
error: scheme.errorPalette,
}).map(([name, palette]) => ({
name,
keyColor: coordinates(palette.keyColor.toInt()),
tones: Array.from({ length: 11 }, (_, index) => ({
requestedTone: index * 10,
...coordinates(palette.tone(index * 10)),
})),
}));
const originalCustom = argbFromHex(input.customHex),
custom = input.harmonize
? Blend.harmonize(originalCustom, source.toInt())
: originalCustom;
const customPalette = TonalPalette.fromInt(custom);
const preview = {
surface: hexFromArgb(scheme.surface),
onSurface: hexFromArgb(scheme.onSurface),
primary: hexFromArgb(scheme.primary),
onPrimary: hexFromArgb(scheme.onPrimary),
};
const safePreview =
opaqueColorRatio(preview.surface, preview.onSurface) >= 4.5 &&
opaqueColorRatio(preview.primary, preview.onPrimary) >= 4.5;
const capturedInput = {
sourceMode: input.sourceMode,
sourceHex: input.sourceMode === 'hex' ? input.sourceHex : null,
requestedHct:
input.sourceMode === 'hct'
? { hue: input.hue, chroma: input.chroma, tone: input.tone }
: null,
variant: input.variant,
specVersion: input.specVersion,
isDark: input.isDark,
contrastLevel: input.contrastLevel,
customHex: input.customHex,
harmonize: input.harmonize,
};
return {
library: '@material/material-color-utilities@0.4.0',
input: capturedInput,
requestedSpec: input.specVersion,
effectiveSpec: scheme.specVersion,
platform: scheme.platform,
source: coordinates(source.toInt()),
roles,
palettes,
custom: {
original: coordinates(originalCustom),
result: coordinates(custom),
harmonized: input.harmonize,
tones: Array.from({ length: 11 }, (_, index) => ({
requestedTone: index * 10,
...coordinates(customPalette.tone(index * 10)),
})),
},
preview,
safePreview,
};
}
Compiled explorer
'use client';
import { useEffect, useId, useRef, useState, type CSSProperties } from 'react';
import {
hexFromArgb,
sourceColorFromImage,
} from '@material/material-color-utilities';
import { Button } from '@mobtranslate/ui';
import { CodeBlock } from '../components/code-block';
import {
buildColorProposal,
initialColorInput,
validateColorInput,
type ColorProposalInput,
} from '../lib/color-proposal';
import './color-proposal.css';
import fixture from '../public/fixtures/color-source.json';
export function ColorProposalExample() {
const id = useId();
const [draft, setDraft] = useState(initialColorInput);
const [coordinates, setCoordinates] = useState({
hue: '49',
chroma: '45',
tone: '50',
});
const [proposal, setProposal] = useState(() =>
buildColorProposal(initialColorInput),
);
const [roleName, setRoleName] = useState('primary'),
[paletteName, setPaletteName] = useState('primary');
const [ready, setReady] = useState(false),
[status, setStatus] = useState(
'Initial proposal. Shared Earth tokens are unchanged.',
);
const [extracting, setExtracting] = useState(false);
const image = useRef<HTMLImageElement>(null),
sourceEpoch = useRef(0),
extractingRef = useRef(false);
useEffect(() => {
setReady(true);
return () => {
sourceEpoch.current++;
};
}, []);
const number = (value: string) => (value.trim() === '' ? NaN : Number(value));
const input: ColorProposalInput = {
...draft,
hue: number(coordinates.hue),
chroma: number(coordinates.chroma),
tone: number(coordinates.tone),
};
const errors = validateColorInput(input),
valid = errors.length === 0;
const role =
proposal.roles.find((candidate) => candidate.name === roleName) ??
proposal.roles[0];
const palette =
proposal.palettes.find((candidate) => candidate.name === paletteName) ??
proposal.palettes[0];
const capture = () => {
if (!valid) return;
const next = buildColorProposal(input);
setProposal(next);
setRoleName((current) =>
next.roles.some((candidate) => candidate.name === current)
? current
: next.roles[0].name,
);
setStatus(
'Captured a local color proposal. No shared token, source record or publication changed.',
);
};
const extract = async () => {
if (extractingRef.current || !image.current) return;
const token = sourceEpoch.current,
fixture = image.current;
extractingRef.current = true;
setExtracting(true);
setStatus(
'Reading the original local fixture and selecting a seed with the SDK.',
);
try {
await fixture.decode();
const sourceHex = hexFromArgb(await sourceColorFromImage(fixture));
if (token !== sourceEpoch.current) {
setStatus(
'Source settings changed while extracting. Your draft source was retained.',
);
return;
}
setDraft((current) => ({ ...current, sourceMode: 'hex', sourceHex }));
setStatus(
`SDK selected ${sourceHex} from the local four-band fixture. Capture explicitly to generate a proposal.`,
);
} catch {
if (token === sourceEpoch.current)
setStatus(
'Fixture seed extraction failed. The draft source and captured proposal are retained.',
);
} finally {
extractingRef.current = false;
setExtracting(false);
}
};
const previewStyle = {
backgroundColor: proposal.preview.surface,
color: proposal.preview.onSurface,
'--color-background': proposal.preview.surface,
'--color-foreground': proposal.preview.onSurface,
'--color-muted-foreground': proposal.preview.onSurface,
'--color-primary': proposal.preview.primary,
'--color-primary-foreground': proposal.preview.onPrimary,
'--color-primary-hover': proposal.preview.primary,
'--color-primary-active': proposal.preview.primary,
'--color-ring': proposal.preview.onSurface,
} as CSSProperties;
const css = `/* Material role namespace only; not a complete MobTranslate theme. */\n.generated-color-proposal {\n${proposal.roles.map((value) => ` --proposal-${value.name.replaceAll('_', '-')}: ${value.hex};`).join('\n')}\n}`;
const realized = (value: number) => value.toFixed(3);
return (
<div className="color-proposal-example space-y-4" data-color-proposal-ready={ready}>
<p>
Generate a captured proposal with Material Color Utilities 0.4.0. The
algorithm’s platform parameter is phone; it does not detect your device
or read a wallpaper. Preview theme is independent of this page’s
light/dark theme.
</p>
<div>
<img
ref={image}
src="/fixtures/color-source.svg"
width={fixture.width}
height={fixture.height}
alt={fixture.description}
/>
<p>
Original vector fixture: {fixture.width}×{fixture.height} pixels. Band specification: {fixture.bands.map(band=>`${band.width}×${fixture.height} ${band.hex}`).join('; ')}. It is not a source photograph or cultural asset.
</p>
<div className="color-proposal-actions">
<Button
type="button"
variant="outline"
disabled={!ready}
aria-disabled={extracting || undefined}
aria-busy={extracting}
data-color-extract
onClick={extract}
>
Extract seed from local fixture
</Button>
</div>
</div>
<fieldset className="grid gap-4 sm:grid-cols-2">
<legend className="font-semibold mb-3">Draft source and scheme</legend>
<label htmlFor={`${id}-source-mode`}>
Source mode
<select
id={`${id}-source-mode`}
data-color-input="sourceMode"
className="mt-input mt-2 w-full"
disabled={!ready}
value={draft.sourceMode}
onChange={(event) => {
sourceEpoch.current++;
const sourceMode = event.currentTarget
.value as ColorProposalInput['sourceMode'];
setDraft((current) => ({ ...current, sourceMode }));
}}
>
<option value="hex">Opaque sRGB hex</option>
<option value="hct">Requested HCT coordinates</option>
</select>
</label>
{draft.sourceMode === 'hex' ? (
<label htmlFor={`${id}-source`}>
Source hex
<input
id={`${id}-source`}
data-color-input="sourceHex"
className="mt-input mt-2 w-full"
spellCheck={false}
disabled={!ready}
value={draft.sourceHex}
aria-invalid={!/^#[\da-f]{6}$/i.test(draft.sourceHex)}
aria-describedby={`${id}-help ${id}-errors`}
onChange={(event) => {
sourceEpoch.current++;
const sourceHex = event.currentTarget.value;
setDraft((current) => ({ ...current, sourceHex }));
}}
/>
</label>
) : (
<div className="grid gap-3">
{(['hue', 'chroma', 'tone'] as const).map((key) => (
<label htmlFor={`${id}-${key}`} key={key}>
Requested {key}
<input
id={`${id}-${key}`}
data-color-input={key}
type="number"
min="0"
max={key === 'hue' ? 360 : key === 'chroma' ? 150 : 100}
step="any"
className="mt-input mt-2 w-full"
disabled={!ready}
value={coordinates[key]}
aria-invalid={
!Number.isFinite(input[key]) ||
input[key] < 0 ||
input[key] >
(key === 'hue' ? 360 : key === 'chroma' ? 150 : 100)
}
aria-describedby={`${id}-help ${id}-errors`}
onChange={(event) => {
sourceEpoch.current++;
const value = event.currentTarget.value;
setCoordinates((current) => ({ ...current, [key]: value }));
}}
/>
</label>
))}
</div>
)}
<label htmlFor={`${id}-variant`}>
Scheme variant
<select
id={`${id}-variant`}
data-color-input="variant"
className="mt-input mt-2 w-full"
disabled={!ready}
value={draft.variant}
onChange={(event) => {
const variant = event.currentTarget
.value as ColorProposalInput['variant'];
setDraft((current) => ({ ...current, variant }));
}}
>
<option value="tonal">Tonal spot</option>
<option value="expressive">Expressive</option>
<option value="fidelity">Fidelity</option>
</select>
</label>
<label htmlFor={`${id}-spec`}>
Requested specification
<select
id={`${id}-spec`}
data-color-input="specVersion"
className="mt-input mt-2 w-full"
disabled={!ready}
value={draft.specVersion}
onChange={(event) => {
const specVersion = event.currentTarget
.value as ColorProposalInput['specVersion'];
setDraft((current) => ({ ...current, specVersion }));
}}
>
<option value="2025">2025</option>
<option value="2021">2021</option>
</select>
</label>
<label htmlFor={`${id}-contrast`}>
Contrast level
<select
id={`${id}-contrast`}
data-color-input="contrastLevel"
className="mt-input mt-2 w-full"
disabled={!ready}
value={draft.contrastLevel}
onChange={(event) => {
const contrastLevel = Number(event.currentTarget.value);
setDraft((current) => ({ ...current, contrastLevel }));
}}
>
<option value="0">Standard (0)</option>
<option value="0.5">Medium (0.5)</option>
<option value="1">High (1)</option>
<option value="-1">Low, experimental (−1)</option>
</select>
</label>
<label className="flex items-center gap-3 min-h-11">
<input
data-color-input="isDark"
type="checkbox"
disabled={!ready}
checked={draft.isDark}
onChange={(event) => {
const isDark = event.currentTarget.checked;
setDraft((current) => ({ ...current, isDark }));
}}
/>
Generate a dark proposal
</label>
<label htmlFor={`${id}-custom`}>
Custom color hex
<input
id={`${id}-custom`}
data-color-input="customHex"
className="mt-input mt-2 w-full"
spellCheck={false}
disabled={!ready}
value={draft.customHex}
aria-invalid={!/^#[\da-f]{6}$/i.test(draft.customHex)}
aria-describedby={`${id}-help ${id}-errors`}
onChange={(event) => {
const customHex = event.currentTarget.value;
setDraft((current) => ({ ...current, customHex }));
}}
/>
</label>
<label className="flex items-center gap-3 min-h-11">
<input
data-color-input="harmonize"
type="checkbox"
disabled={!ready}
checked={draft.harmonize}
onChange={(event) => {
const harmonize = event.currentTarget.checked;
setDraft((current) => ({ ...current, harmonize }));
}}
/>
Harmonize this custom color toward the source
</label>
</fieldset>
<p id={`${id}-help`}>
Hex colors must use #RRGGBB. Requested hue is 0–360°, chroma 0–150 and
tone 0–100. The sRGB gamut can reduce realized chroma; HCT is not HSL.
Custom-color harmonization does not rewrite the generated semantic
roles.
</p>
<p id={`${id}-errors`} data-color-errors>
{valid
? 'Draft settings are valid; capture to apply them to the local proposal.'
: errors.join(' ')}
</p>
<div className="color-proposal-actions">
<Button
type="button"
data-color-capture
disabled={!ready || !valid}
onClick={capture}
>
Capture color proposal
</Button>
</div>
<p role="status" data-color-status>
{status}
</p>
<dl className="grid gap-3 sm:grid-cols-2" data-color-captured>
<div>
<dt>Captured variant/theme/contrast</dt>
<dd>
{proposal.input.variant}; {proposal.input.isDark ? 'dark' : 'light'}
; {proposal.input.contrastLevel}
</dd>
</div>
<div>
<dt>Requested → effective specification</dt>
<dd data-color-spec>
{proposal.requestedSpec} → {proposal.effectiveSpec}
</dd>
</div>
<div>
<dt>Realized source</dt>
<dd data-color-source>{proposal.source.hex}</dd>
</div>
<div>
<dt>Realized hue/chroma/tone</dt>
<dd data-color-realized>
{realized(proposal.source.hue)} / {realized(proposal.source.chroma)}{' '}
/ {realized(proposal.source.tone)}
</dd>
</div>
<div>
<dt>Captured custom color</dt>
<dd data-color-custom>
{proposal.custom.original.hex} → {proposal.custom.result.hex};
harmonization {proposal.custom.harmonized ? 'enabled' : 'disabled'}
</dd>
</div>
<div>
<dt>Available role values</dt>
<dd>
{proposal.roles.length}, derived from the SDK’s current allColors
collection
</dd>
</div>
</dl>
{proposal.input.requestedHct && (
<p>
Requested HCT: {proposal.input.requestedHct.hue} /{' '}
{proposal.input.requestedHct.chroma} /{' '}
{proposal.input.requestedHct.tone}. Compare the realized coordinates
above; the request is not an achieved-gamut guarantee.
</p>
)}
<p>
SDK fallback is explicit. Some variants resolve a requested 2025 scheme
through 2021 rules. A specification/variant name is not a conformance or
contrast certificate.
</p>
{proposal.safePreview ? (
<div
data-color-preview
className="color-proposal-preview"
style={previewStyle}
>
<h3 className="font-semibold">Scoped action specimen</h3>
<p>
Only this surface and action use the captured role pairs.
Hover/pressed keep the same fill here; a full state system needs
additional design and verification.
</p>
<Button
type="button"
onClick={() =>
setStatus(
'Proposal action activated locally. The shared Earth theme and source records are unchanged.',
)
}
>
Activate proposal action
</Button>
</div>
) : (
<p data-color-preview-suppressed>
Text/action specimen suppressed: at least one proposed opaque text
pair is below 4.5:1. All role values and measurements remain available
in the neutral interface.
</p>
)}
<label htmlFor={`${id}-role`}>
Inspect a captured role
<select
id={`${id}-role`}
data-color-role-select
className="mt-input mt-2 w-full"
value={role.name}
onChange={(event) => setRoleName(event.currentTarget.value)}
>
{proposal.roles.map((value) => (
<option key={value.name} value={value.name}>
{value.name}
</option>
))}
</select>
</label>
<div data-color-role-detail>
<p className="font-semibold">
{role.name}: {role.hex}; realized tone {realized(role.tone)}
</p>
<span
className="color-proposal-swatch"
aria-hidden="true"
style={{ backgroundColor: role.hex }}
/>
<p>
SDK background classification:{' '}
{role.isBackground ? 'background' : 'not background'}. SDK
contrast-curve target: {role.sdkTarget ?? 'not declared'}.
</p>
{role.backgrounds.length ? (
<ul className="guidance-list">
{role.backgrounds.map((background) => (
<li key={background.name}>
{background.name} ({background.hex}):{' '}
{background.ratio.toFixed(3)}:1. AA normal-text threshold:{' '}
{background.ratio >= 4.5 ? 'passes' : 'fails'}; 3:1 comparison:{' '}
{background.ratio >= 3 ? 'passes' : 'fails'}.
</li>
))}
</ul>
) : (
<p>
No background relationship is declared for this role. The explorer
does not invent a text pairing.
</p>
)}
</div>
<div
className="doc-table"
role="region"
tabIndex={0}
aria-label="Generated roles and declared background contrast"
>
<table>
<thead>
<tr>
<th scope="col">Role</th>
<th scope="col">Hex</th>
<th scope="col">Declared backgrounds and ratios</th>
</tr>
</thead>
<tbody>
{proposal.roles.map((value) => (
<tr key={value.name}>
<th scope="row">{value.name}</th>
<td>{value.hex}</td>
<td>
{value.backgrounds.length
? value.backgrounds
.map(
(background) =>
`${background.name}: ${background.ratio.toFixed(3)}:1`,
)
.join('; ')
: 'No declared pairing'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<label htmlFor={`${id}-palette`}>
Inspect a captured tonal palette
<select
id={`${id}-palette`}
data-color-palette-select
className="mt-input mt-2 w-full"
value={palette.name}
onChange={(event) => setPaletteName(event.currentTarget.value)}
>
{proposal.palettes.map((value) => (
<option key={value.name} value={value.name}>
{value.name}
</option>
))}
</select>
</label>
<p>
Palette key color: {palette.keyColor.hex}; realized HCT{' '}
{realized(palette.keyColor.hue)} / {realized(palette.keyColor.chroma)} /{' '}
{realized(palette.keyColor.tone)}.
</p>
<div
className="doc-table"
role="region"
tabIndex={0}
aria-label="Captured tonal palette"
>
<table>
<thead>
<tr>
<th scope="col">Requested tone</th>
<th scope="col">Realized hex/tone/chroma</th>
<th scope="col">Swatch</th>
</tr>
</thead>
<tbody>
{palette.tones.map((value) => (
<tr key={value.requestedTone}>
<th scope="row">{value.requestedTone}</th>
<td>
{value.hex}; {realized(value.tone)}; {realized(value.chroma)}
</td>
<td>
<span
className="color-proposal-swatch"
aria-hidden="true"
style={{ backgroundColor: value.hex }}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<details data-color-full>
<summary className="min-h-11 cursor-pointer">
Full captured request, roles, palettes and custom color
</summary>
{ready ? <CodeBlock language="json" code={JSON.stringify(proposal, null, 2)} /> :
<p role="status">Preparing the full-precision captured output.</p>}
</details>
<details>
<summary className="min-h-11 cursor-pointer">
Role namespace CSS export
</summary>
<CodeBlock language="css" code={css} />
</details>
<p className="text-sm">
All measurements use opaque sRGB role values. They do not cover
overlays, every interaction state, color perception or cultural
authority. Extraction reads only the original local fixture; wallpaper,
browser personal color and remote source records are not read.
</p>
</div>
);
}
Scoped control CSS
.color-proposal-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.color-proposal-example fieldset,
.color-proposal-example label,
.color-proposal-example input,
.color-proposal-example select {
min-inline-size: 0;
max-inline-size: 100%;
}
.color-proposal-example p,
.color-proposal-example dt,
.color-proposal-example dd,
.color-proposal-example summary {
overflow-wrap: anywhere;
}
.color-proposal-example dl > div {
min-inline-size: 0;
}
.color-proposal-actions .mt-btn,
.color-proposal-preview .mt-btn {
min-block-size: 44px;
block-size: auto;
max-inline-size: 100%;
white-space: normal;
overflow-wrap: anywhere;
}
.color-proposal-preview {
padding: 16px;
border: 1px solid currentColor;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
align-items: start;
gap: 12px;
min-inline-size: 0;
}
.color-proposal-preview p,
.color-proposal-preview h3 {
overflow-wrap: anywhere;
}
.color-proposal-swatch {
display: inline-block;
inline-size: 44px;
block-size: 44px;
border: 1px solid var(--color-muted-foreground);
vertical-align: middle;
}
Original extraction fixture
The 64×64 authored vector is the only image source read by the extraction action. The swatch array and dimensions are explicit, and the chosen seed is reported as an algorithm result rather than a source identity or endorsement.
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><rect width="16" height="64" fill="#b45e2a"/><rect x="16" width="16" height="64" fill="#386b4f"/><rect x="32" width="16" height="64" fill="#395d75"/><rect x="48" width="16" height="64" fill="#faf8f5"/></svg>
Verification and failure cases
- Compare generated role values with the pinned SDK, including light/dark, variants, contrast levels and specification fallback.
- Compare requested versus realized high-chroma/light-tone HCT; verify impossible gamut requests are not labelled exact.
- Capture, edit the draft and inspect the retained JSON. Blank/invalid active settings must leave the capture unchanged.
- Extract from the actual fixture; compare the selected seed with the quantizer/ranking over its known pixels. Exercise image/canvas failure and held extraction with a competing source edit.
- Toggle harmonization and verify custom output changes independently of generated roles.
- Activate the local specimen and inspect computed colors; confirm root Earth tokens and unrelated controls remain unchanged.
- Check both global themes, independently selected proposal theme, 320px, text enlargement, keyboard fields/actions/full exports and accessibility scenes.
- Do not claim generated values or nominal SDK targets prove every production state, transparency layer, perceptual need or cultural authority.