feat(words): parametric color marks — foreground + background pickers (COLOR-3)

Marks now support parametric values via colon syntax:
- color:#hex    — foreground text color
- bgcolor:#hex  — highlight / background color

Existing boolean marks (bold/italic/underline/strike/code) are
unchanged. Empty value (color:, bgcolor:) clears that slot.

Engine
- WordsMark union extended with `color:\${string}` and
  `bgcolor:\${string}` template literal variants.
- isWordsMark accepts the parametric form (validates against #hex
  3/4/6/8 digits).
- normalizeMarks de-duplicates by prefix for parametric marks (only
  one color, one bgcolor per inline survives).
- toggleMarkSet REPLACES same-prefix entries instead of toggling
  them; empty value strips the slot.
- New helpers markValue() and markPrefix() for reading parametric
  data.

Serializers
- HTML out: wrapMarks collects color/bgcolor into a single
  `<span style="color:..;background-color:..">` instead of nested
  spans, then wraps with the boolean-mark tags.
- HTML in: not extended this pass — round-trip via style attr is a
  follow-up. The marks survive in the document model + render OK.
- Markdown out: silently drops parametric marks (no MD syntax for
  inline color; HTML round-trip is the supported path).

DOM render
- createWordsTextAttrs splits marks into booleans (→
  data-words-marks) and parametrics (→ inline `style` attr) so the
  contenteditable paints the colors directly.

Drawer Format panel
- New "Foreground" + "Background" sections with:
  - 10-swatch palette (red/orange/amber/green/cyan/blue/violet/pink/
    white/grey) — hand-picked editorial set
  - Native <input type=color> for free hex pick (no preset constraint)
  - "×" clear button (only when a value is set for that slot)
  - Active swatch outline when the current value matches the preset
- runCommand passes the `color:#hex` / `bgcolor:#hex` string straight
  through commandFromName → toggleMark.

WordsCommandName extended with `color:\${string}` and
`bgcolor:\${string}` for type-safe consumer calls.

152/152 tests pass. Verified visually: clicking the red swatch on
selected "ActiveUI" text in the demo produces
`<span data-words-marks="bold" style="color:#ef4444">ActiveUI</span>`
in the rendered HTML, text paints red in the editor, the swatch
shows active outline + clear (×) button appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent ab06562abe
commit c1c3cd9404

@ -94,6 +94,32 @@
{ id: 'bash', label: 'Bash' },
{ id: 'python', label: 'Py' }
] as const;
/** Hand-picked palette — keeps the picker compact while covering
* most editorial intents. Free hex still available via the input
* next to the chips. */
const COLOR_PRESETS = [
'#ef4444', // red
'#f97316', // orange
'#eab308', // amber
'#22c55e', // green
'#06b6d4', // cyan
'#3b82f6', // blue
'#8b5cf6', // violet
'#ec4899', // pink
'#ffffff', // white
'#a1a1aa' // grey
] as const;
/** Read the parametric mark value for a given prefix from the
* active marks. Returns '' when not set. */
function currentParametricMark(
marks: readonly string[],
prefix: 'color' | 'bgcolor'
): string {
const found = marks.find((m) => m.startsWith(`${prefix}:`));
return found ? found.slice(prefix.length + 1) : '';
}
</script>
<Words.Drawer bind:open {defaultOpen} {disabled} {...rest}>
@ -268,6 +294,78 @@
</button>
{/each}
</div>
<div data-words-drawer-section-subtitle>Foreground</div>
<div data-words-drawer-color-picker>
{#each COLOR_PRESETS as preset (preset)}
<button
type="button"
data-words-drawer-color-swatch
data-active={currentParametricMark(s.activeMarks, 'color') === preset ? '' : undefined}
style="background-color:{preset}"
title={preset}
aria-label={`Set foreground to ${preset}`}
onmousedown={(e) => e.preventDefault()}
onclick={() => s.runCommand(`color:${preset}` as never)}
></button>
{/each}
<input
type="color"
data-words-drawer-color-input
value={currentParametricMark(s.activeMarks, 'color') || '#000000'}
aria-label="Pick custom foreground color"
oninput={(e) => {
const v = (e.currentTarget as HTMLInputElement).value;
s.runCommand(`color:${v}` as never);
}}
/>
{#if currentParametricMark(s.activeMarks, 'color')}
<button
type="button"
data-words-drawer-color-clear
title="Clear foreground"
aria-label="Clear foreground color"
onmousedown={(e) => e.preventDefault()}
onclick={() => s.runCommand('color:' as never)}
>×</button>
{/if}
</div>
<div data-words-drawer-section-subtitle>Background</div>
<div data-words-drawer-color-picker>
{#each COLOR_PRESETS as preset (preset)}
<button
type="button"
data-words-drawer-color-swatch
data-active={currentParametricMark(s.activeMarks, 'bgcolor') === preset ? '' : undefined}
style="background-color:{preset}"
title={preset}
aria-label={`Set background to ${preset}`}
onmousedown={(e) => e.preventDefault()}
onclick={() => s.runCommand(`bgcolor:${preset}` as never)}
></button>
{/each}
<input
type="color"
data-words-drawer-color-input
value={currentParametricMark(s.activeMarks, 'bgcolor') || '#ffff00'}
aria-label="Pick custom background color"
oninput={(e) => {
const v = (e.currentTarget as HTMLInputElement).value;
s.runCommand(`bgcolor:${v}` as never);
}}
/>
{#if currentParametricMark(s.activeMarks, 'bgcolor')}
<button
type="button"
data-words-drawer-color-clear
title="Clear background"
aria-label="Clear background color"
onmousedown={(e) => e.preventDefault()}
onclick={() => s.runCommand('bgcolor:' as never)}
>×</button>
{/if}
</div>
<div data-words-drawer-actions data-flush>
<button
type="button"

@ -397,6 +397,72 @@
margin-block-start: 0.4rem;
}
/* Color picker — palette of swatches + a native <input type=color> for
free-pick + a clear button when a value is set. */
[data-words-drawer-color-picker] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
}
[data-words-drawer-color-swatch] {
inline-size: 1.25rem;
block-size: 1.25rem;
padding: 0;
border: 1px solid color-mix(in srgb, var(--words-toolbar-border) 60%, transparent);
border-radius: 999px;
cursor: pointer;
transition: transform 80ms ease, box-shadow 80ms ease;
}
[data-words-drawer-color-swatch]:hover {
transform: scale(1.15);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--_words-accent-solid) 30%, transparent);
}
[data-words-drawer-color-swatch][data-active] {
box-shadow:
0 0 0 2px var(--words-bg),
0 0 0 4px var(--_words-accent-solid);
}
[data-words-drawer-color-input] {
inline-size: 1.6rem;
block-size: 1.4rem;
padding: 0;
border: 1px dashed color-mix(in srgb, var(--words-toolbar-border) 80%, transparent);
border-radius: var(--words-command-radius);
background: transparent;
cursor: pointer;
}
[data-words-drawer-color-input]:hover {
border-style: solid;
border-color: var(--_words-accent-border);
}
[data-words-drawer-color-clear] {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 1.25rem;
block-size: 1.25rem;
padding: 0;
border: 1px solid color-mix(in srgb, var(--words-toolbar-border) 60%, transparent);
border-radius: 999px;
background: transparent;
color: var(--words-status-color);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
}
[data-words-drawer-color-clear]:hover {
border-color: var(--words-threat-text);
color: var(--words-threat-text);
}
[data-words-drawer-section-title] {
display: flex;
align-items: baseline;

@ -1,6 +1,24 @@
export const WORDS_DOCUMENT_VERSION = 1;
export type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code';
/**
* Inline mark — boolean format flags ('bold', 'italic', ...) plus
* parametric marks carrying a value via colon-prefixed syntax:
* - `color:#ff0000` foreground text color (hex with #)
* - `bgcolor:#ffeeaa` highlight / background color
*
* Parametric marks REPLACE same-prefix entries when toggled (you
* can have at most one of each prefix per inline). The HTML
* serializer emits them as `style="color:..; background-color:..."`.
* Boolean marks toggle as before.
*/
export type WordsMark =
| 'bold'
| 'italic'
| 'underline'
| 'strike'
| 'code'
| `color:${string}`
| `bgcolor:${string}`;
export type WordsBlockType =
| 'paragraph'
| 'heading'
@ -242,8 +260,30 @@ export function createLink(
export function normalizeMarks(marks: readonly WordsMark[] | undefined): readonly WordsMark[] {
if (!marks?.length) return [];
const order = new Map<WordsMark, number>(WORDS_MARKS.map((mark, index) => [mark, index]));
return Array.from(new Set(marks)).sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
const order = new Map<string, number>(
(WORDS_MARKS as readonly string[]).map((mark, index) => [mark, index])
);
// Dedupe boolean marks; for parametric marks (color:X, bgcolor:Y)
// keep only the LAST value per prefix so multiple toggles in a
// row resolve to the user's final choice.
const seenPrefix = new Map<string, WordsMark>();
const booleanSet = new Set<WordsMark>();
for (const mark of marks) {
const colon = mark.indexOf(':');
if (colon > 0) {
seenPrefix.set(mark.slice(0, colon), mark);
} else {
booleanSet.add(mark);
}
}
const dedup = [...booleanSet, ...seenPrefix.values()];
// Sort: boolean marks first in declaration order, parametric marks
// after (stable). This keeps existing tests happy.
return dedup.sort((a, b) => {
const ai = order.get(a) ?? 1e6;
const bi = order.get(b) ?? 1e6;
return ai - bi;
});
}
export function sameMarks(
@ -257,7 +297,30 @@ export function sameMarks(
}
export function isWordsMark(value: unknown): value is WordsMark {
return typeof value === 'string' && (WORDS_MARKS as readonly string[]).includes(value);
if (typeof value !== 'string') return false;
if ((WORDS_MARKS as readonly string[]).includes(value)) return true;
// Parametric: `color:#hex` or `bgcolor:#hex`. Liberal hex check —
// anything that's `#` + 3/4/6/8 hex digits.
const colorMatch = value.match(/^(color|bgcolor):(#[0-9a-fA-F]{3,8})$/);
return !!colorMatch;
}
/**
* Read the parametric value carried by a mark, or undefined for
* boolean marks. Returns the part after the colon.
*
* markValue('color:#ff0000') // '#ff0000'
* markValue('bold') // undefined
*/
export function markValue(mark: WordsMark): string | undefined {
const colon = mark.indexOf(':');
return colon > 0 ? mark.slice(colon + 1) : undefined;
}
/** Prefix of a parametric mark, or the mark itself for boolean ones. */
export function markPrefix(mark: WordsMark): string {
const colon = mark.indexOf(':');
return colon > 0 ? mark.slice(0, colon) : mark;
}
export function isWordsHeadingLevel(value: unknown): value is WordsHeadingLevel {

@ -62,10 +62,33 @@ export function createWordsTextAttrs(
marks: readonly WordsMark[] | undefined,
text: string
): WordsDomTextAttrs {
// Marks may contain parametric entries (`color:#hex`, `bgcolor:#hex`)
// — those translate to an inline `style` attr so the rendered span
// paints the color directly. Boolean marks stay in `data-words-marks`
// so the existing eidos CSS selectors still apply.
const booleans: string[] = [];
const styles: string[] = [];
if (marks) {
for (const mark of marks) {
const colon = mark.indexOf(':');
if (colon > 0) {
const prefix = mark.slice(0, colon);
const val = mark.slice(colon + 1);
if (prefix === 'color' && /^#[0-9a-fA-F]{3,8}$/.test(val)) {
styles.push(`color:${val}`);
} else if (prefix === 'bgcolor' && /^#[0-9a-fA-F]{3,8}$/.test(val)) {
styles.push(`background-color:${val}`);
}
} else {
booleans.push(mark);
}
}
}
return {
[WORDS_NODE_ATTR]: 'text',
[WORDS_PATH_ATTR]: encodeWordsPath(path),
...(marks?.length ? { [WORDS_MARKS_ATTR]: marks.join(' ') } : {}),
...(booleans.length ? { [WORDS_MARKS_ATTR]: booleans.join(' ') } : {}),
...(styles.length ? { style: styles.join(';') } : {}),
...(text.length === 0 ? { [WORDS_EMPTY_TEXT_ATTR]: '' } : {})
};
}

@ -2665,6 +2665,15 @@ function inlineChildrenText(inlines: readonly WordsInline[]): string {
}
function toggleMarkSet(marks: readonly WordsMark[], mark: WordsMark): readonly WordsMark[] {
const colon = mark.indexOf(':');
if (colon > 0) {
// Parametric mark — REPLACE any existing entry with the same
// prefix. Empty value (`color:`) clears the parametric mark.
const prefix = mark.slice(0, colon + 1);
const filtered = marks.filter((m) => !m.startsWith(prefix));
if (mark.length === colon + 1) return normalizeMarks(filtered);
return normalizeMarks([...filtered, mark]);
}
return normalizeMarks(marks.includes(mark) ? marks.filter((m) => m !== mark) : [...marks, mark]);
}

@ -25,13 +25,15 @@ import {
} from '../extensions/table/serialize-html';
import { createImage, isWordsImageAlign } from '../extensions/image';
/** Boolean-mark to HTML tag map. Parametric marks (color/bgcolor) are
* serialized separately via a <span style=...>; not in this table. */
const MARK_TAGS = {
bold: 'strong',
italic: 'em',
underline: 'u',
strike: 's',
code: 'code'
} as const satisfies Record<WordsMark, string>;
} as const;
type HtmlNode = HtmlElementNode | HtmlTextNode;
@ -181,10 +183,27 @@ function serializeInlineHtml(inline: WordsInline): string {
}
function wrapMarks(value: string, marks: readonly WordsMark[]): string {
return marks.reduce((html, mark) => {
const tag = MARK_TAGS[mark];
// Parametric marks (color, bgcolor) get collapsed into ONE span with
// a style attr so we don't nest <span> per color slot.
const stylePieces: string[] = [];
const wrapped = marks.reduce((html, mark) => {
const colon = mark.indexOf(':');
if (colon > 0) {
const prefix = mark.slice(0, colon);
const val = mark.slice(colon + 1);
if (prefix === 'color' && /^#[0-9a-fA-F]{3,8}$/.test(val)) {
stylePieces.push(`color:${val}`);
} else if (prefix === 'bgcolor' && /^#[0-9a-fA-F]{3,8}$/.test(val)) {
stylePieces.push(`background-color:${val}`);
}
return html;
}
const tag = MARK_TAGS[mark as keyof typeof MARK_TAGS];
if (!tag) return html;
return `<${tag}>${html}</${tag}>`;
}, value);
if (stylePieces.length === 0) return wrapped;
return `<span style="${escapeHtmlAttr(stylePieces.join(';'))}">${wrapped}</span>`;
}
function escapeHtml(value: string): string {

@ -231,7 +231,11 @@ function serializeInlineMarkdown(inline: WordsInline): string {
function wrapMarkdownMarks(value: string, marks: readonly WordsMark[]): string {
return normalizeMarks(marks).reduce((text, mark) => {
const [open, close] = MARK_DELIMITERS[mark];
// Parametric marks (color, bgcolor) have no markdown equivalent —
// drop them on export. They round-trip via HTML instead.
const delim = (MARK_DELIMITERS as Record<string, readonly [string, string]>)[mark];
if (!delim) return text;
const [open, close] = delim;
return `${open}${text}${close}`;
}, value);
}

@ -90,7 +90,11 @@ export type WordsCommandName =
| 'clear-document'
| 'find-text'
| 'replace-all-text'
| 'commit';
| 'commit'
/** Parametric color marks — pass through commandFromName to the
* engine's toggleMark op. Empty value (`color:`) clears. */
| `color:${string}`
| `bgcolor:${string}`;
export type WordsProviderSnippetProps = {
readonly document: WordsDocument;

@ -1623,6 +1623,13 @@ export class WordsProvider {
}
private commandFromName(name: WordsCommandName, level?: 1 | 2 | 3): WordsCommand | undefined {
// Parametric color marks come through as `color:#hex` /
// `bgcolor:#hex` (or with empty value to clear). Pass them
// directly to the toggleMark op — the engine's toggleMarkSet
// REPLACES same-prefix marks instead of toggling them.
if (name.startsWith('color:') || name.startsWith('bgcolor:')) {
return { type: 'toggleMark', mark: name as WordsMark };
}
switch (name) {
case 'bold':
case 'italic':

Loading…
Cancel
Save

Powered by TurnKey Linux.