Compare commits

17 changed files with 299 additions and 776 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
{
"monorepoCommit": "e33714a5ca38429d1bdb77a961a14461697944f5",
"monorepoCommit": "be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9",
"sourcePath": "examples/local-first/packages/language-reader-presentation"
}
+1 -1
View File
@@ -7,7 +7,7 @@ query Reader {
defaultReader {_camino {ref}}
inferenceProfile
documents(first: 100) { entries { node { archived data { title source url attribution } } } }
features(first: 200) { entries { node { authoring {_camino {ref}} options data { name processor enabled configRevision } } } }
features(first: 200) { entries { node { authoring {_camino {ref}} options data { name processor style reveal generation scope color enabled configRevision } } } }
tasks(first: 20, where: {data: {kind: {in: ["customize", "text"]}}}, orderBy: {data: {createdAt: DESC}}) { entries { node {
reader {_camino {ref}}
document {_camino {ref}}
+20 -20
View File
@@ -1,22 +1,22 @@
quixos-lock version 1 {
quixos source { repository "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git"; commit "e33714a5ca38429d1bdb77a961a14461697944f5"; }
interface LanguageReader source { repository "https://repos.quixos.org/quixos-templates/interface-language-reader.git"; commit "03ab458b996852f1998abfd999e699527ad3553c"; }
interface ReadingReader source { repository "https://repos.quixos.org/quixos-templates/interface-reading-reader.git"; commit "a0e458c19bbf61cc7afa40112e4a8eb30ce4c3c6"; }
interface ReadingDocument source { repository "https://repos.quixos.org/quixos-templates/interface-reading-document.git"; commit "f44ef80714db6fbc81df64369e1c3b1ba8177f48"; }
interface ReadingFeature source { repository "https://repos.quixos.org/quixos-templates/interface-reading-feature.git"; commit "4414205afe1bc9e959083638b565d46dab09ec17"; }
interface ReadingJob source { repository "https://repos.quixos.org/quixos-templates/interface-reading-job.git"; commit "0829b97660a2ed788ac0d29acb21502feea3e139"; }
interface ReadingWord source { repository "https://repos.quixos.org/quixos-templates/interface-reading-word.git"; commit "0b147f9e0b2926687a2a12bfad627ee9bf502a0d"; }
interface ReadingVocabulary source { repository "https://repos.quixos.org/quixos-templates/interface-reading-vocabulary.git"; commit "9124b19926a5201bb380058d1dba08a208175177"; }
interface AgentRequest source { repository "https://repos.quixos.org/quixos-templates/interface-agent-request.git"; commit "6f3f42f1cba542823f925fd94deca3808f2d9763"; }
interface AgentThread source { repository "https://repos.quixos.org/quixos-templates/interface-agent-thread.git"; commit "574f603ba3536dd9583e2a9472dcefc8ba34fd20"; }
interface AgentItem source { repository "https://repos.quixos.org/quixos-templates/interface-agent-item.git"; commit "631c09b123eab90f97ae025ba177d906224c0c8e"; }
interface AgentQuestion source { repository "https://repos.quixos.org/quixos-templates/interface-agent-question.git"; commit "0ca9d75c16db688521c1c4548bd26b883b952e42"; }
interface StandaloneView source { repository "https://repos.quixos.org/quixos-templates/interface-react-standalone-view.git"; commit "3b0be4ad469b41bb83a5f08493024ff2cbe1b569"; }
interface ReactComponent source { repository "https://repos.quixos.org/quixos-templates/interface-react-component.git"; commit "6418a415e8599f0689b09beb16751e6de79e8ce3"; }
interface PresentationMount source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-mount.git"; commit "a5360f6213e11937e14a534091748b86ee9dc3f1"; }
interface PresentationState source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-state.git"; commit "cf0984feed030ecb0e91e4ca88b1bdfb76526505"; }
interface Presentation source { repository "https://repos.quixos.org/quixos-templates/interface-presentation.git"; commit "c97b9060c982963446ffb1732486c067bab63518"; }
interface ReadingNavigation source { repository "https://repos.quixos.org/quixos-templates/interface-reading-navigation.git"; commit "f001eb3813c5e263651474994de7aecf736388c6"; }
interface ReadingServices source { repository "https://repos.quixos.org/quixos-templates/interface-reading-services.git"; commit "187bcfabc2d221f56b0405fd466e7a0ffb53d6ac"; }
interface GutenbergCatalog source { repository "https://repos.quixos.org/quixos-templates/interface-gutenberg-catalog.git"; commit "fe89f54e3970449dc3339d3b85b46a4b55b90a30"; }
quixos source { repository "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git"; commit "be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9"; }
interface LanguageReader source { repository "https://repos.quixos.org/quixos-templates/interface-language-reader.git"; commit "ad65d325d071443ba72edf5a99a5aeaa59f3e7e0"; }
interface ReadingReader source { repository "https://repos.quixos.org/quixos-templates/interface-reading-reader.git"; commit "42b3565df44e6297ef88476fa44fa37e38a70d25"; }
interface ReadingDocument source { repository "https://repos.quixos.org/quixos-templates/interface-reading-document.git"; commit "6ac56d55ee1dd79992cf28c3d760e1cf190225f1"; }
interface ReadingFeature source { repository "https://repos.quixos.org/quixos-templates/interface-reading-feature.git"; commit "2e18f362ce34884e15771b4068197f1cd97c12d4"; }
interface ReadingJob source { repository "https://repos.quixos.org/quixos-templates/interface-reading-job.git"; commit "3bc1b603b65c981e08c8521d111c9dfb983f3d83"; }
interface ReadingWord source { repository "https://repos.quixos.org/quixos-templates/interface-reading-word.git"; commit "9b302fb44e96671832de4aef4e4563dba5af6f2d"; }
interface ReadingVocabulary source { repository "https://repos.quixos.org/quixos-templates/interface-reading-vocabulary.git"; commit "4c8dc969136d0add25c87c8f6cacb00f7dde97c0"; }
interface AgentRequest source { repository "https://repos.quixos.org/quixos-templates/interface-agent-request.git"; commit "7f1e9d6278366329fd76f2698cd5a53c136f9914"; }
interface AgentThread source { repository "https://repos.quixos.org/quixos-templates/interface-agent-thread.git"; commit "6ace62f773c230c468efeabe9b5aac35115b7900"; }
interface AgentItem source { repository "https://repos.quixos.org/quixos-templates/interface-agent-item.git"; commit "6e702e5cab24634d306c8f657f3a0b4873469060"; }
interface AgentQuestion source { repository "https://repos.quixos.org/quixos-templates/interface-agent-question.git"; commit "7d69058c2c12b4ca1800d8ff8218caffa45e5d13"; }
interface StandaloneView source { repository "https://repos.quixos.org/quixos-templates/interface-react-standalone-view.git"; commit "29a974e9db15387c9efc0f0fe64fec348db10344"; }
interface ReactComponent source { repository "https://repos.quixos.org/quixos-templates/interface-react-component.git"; commit "59a8927fdeaba29c51ce5de8fa2cc0ee10a6a134"; }
interface PresentationMount source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-mount.git"; commit "4a322809adfbb50494c9d5dd9889b56c907e737d"; }
interface PresentationState source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-state.git"; commit "b8f27e813b05231c9d3b4d3692e91b8fc2936dec"; }
interface Presentation source { repository "https://repos.quixos.org/quixos-templates/interface-presentation.git"; commit "e11912f9e11a2014a489919294424c00a98f7cf3"; }
interface ReadingNavigation source { repository "https://repos.quixos.org/quixos-templates/interface-reading-navigation.git"; commit "710cab97959f633a0ffd9a757f2dd2cf9b06f77f"; }
interface ReadingServices source { repository "https://repos.quixos.org/quixos-templates/interface-reading-services.git"; commit "ea1ffb89efbc7bdd9c20794cc63dc112055536c8"; }
interface GutenbergCatalog source { repository "https://repos.quixos.org/quixos-templates/interface-gutenberg-catalog.git"; commit "8f5661caa8b6a4abb5bc85c88f1c3843a4c92f38"; }
}
+16 -7
View File
@@ -18,10 +18,6 @@ export const annotationDisplays: Record<
string,
(items: Item[], options: ConfigValues, context?: { source: string; vocabulary: ReadonlySet<string> }) => Item[]
> = {
names: (items, options) =>
items.map((item) => ({ ...item, color: String(options[item.tag + "Color"] ?? "#66866b") })),
grammar: (items, options) =>
items.map((item) => ({ ...item, color: String(options[item.tag + "Color"] ?? options.otherColor ?? "#66866b") })),
frenchEasyVocabulary: (items, options) =>
items.filter((item) => {
const match = /^cefr:(A1|A2):(noun|adjective|verb)$/.exec(item.tag);
@@ -56,13 +52,26 @@ export function presentAnnotations(
options: ConfigValues,
context?: { source: string; vocabulary: ReadonlySet<string> },
) {
return (annotationDisplays[processor]?.(items, options, context) ?? items).map((item) => {
const decorated = items.map((item) => ({
...item,
color:
typeof (options[item.tag + "Color"] ?? options.otherColor) === "string"
? String(options[item.tag + "Color"] ?? options.otherColor)
: undefined,
fontFamily: String(options.fontFamily ?? ""),
fontSize: Number(options.fontSize ?? 13),
underlinePattern: (options.underlinePattern ?? item.underlinePattern ?? "solid") as UnderlinePattern,
placement: (options.placement ?? item.placement ?? "above") as "above" | "below",
}));
return (annotationDisplays[processor]?.(decorated, options, context) ?? decorated).map((item) => {
if (
(item.underlinePattern && !["solid", "dashed", "squiggly"].includes(item.underlinePattern)) ||
(item.placement && item.placement !== "above" && item.placement !== "below") ||
(item.placement !== "above" && item.placement !== "below") ||
(item.color && !/^#[0-9a-f]{6}$/i.test(item.color)) ||
(item.fontFamily && (item.fontFamily.length > 160 || /[\r\n\x00]/.test(item.fontFamily))) ||
(item.fontSize !== undefined && (!Number.isFinite(item.fontSize) || item.fontSize < 6 || item.fontSize > 72))
!Number.isFinite(item.fontSize) ||
item.fontSize! < 6 ||
item.fontSize! > 72
)
throw Error("Invalid annotator appearance");
return item;
+103 -108
View File
@@ -3,19 +3,29 @@ import { ColorPicker, FontPicker, NumberControl, Options, Switch, UnderlinePrevi
import type { ComponentType } from "react";
import type { AnnotationRender } from "./render-hooks.js";
import { configDefaults, type ConfigValue, type ConfigValues, type ConfigNode } from "./config-schema.js";
export type { ConfigValue, ConfigValues, ConfigField, ConfigNode } from "./config-schema.js";
export type ConfigValue = string | number | boolean;
export type ConfigValues = Record<string, ConfigValue>;
export type ConfigField = {
key: string;
label: string;
kind: "color" | "number" | "select" | "toggle" | "text";
default: ConfigValue;
min?: number;
max?: number;
computation?: boolean;
options?: { value: string; label: string }[];
};
export type AnnotatorConfigProps = {
fields: ConfigNode[];
fields: ConfigField[];
values: ConfigValues;
disabled: boolean;
change: (key: string, value: ConfigValue) => void;
};
// Authors may install a purpose-built inline component here. The component edits
// the same validated processor options as the generated controls; it does not
// own persistence or model calls. Every control, including appearance, is opt-in.
// own persistence, model calls, or the generic appearance/reveal preferences.
export const annotatorConfigViews: Record<string, ComponentType<AnnotatorConfigProps>> = {
pinyin: ({ fields, values, disabled, change }) => (
pinyin: ({ values, disabled, change }) => (
<div className="lr-hsk-control">
<label htmlFor="lr-hsk-level">Words above your HSK level</label>
<LocalInput
@@ -42,13 +52,6 @@ export const annotatorConfigViews: Record<string, ComponentType<AnnotatorConfigP
))}
</div>
<strong>{Number(values.minimumLevel ?? 2) ? `Above HSK ${values.minimumLevel ?? 2}` : "All words"}</strong>
<CustomConfig
processor="pinyin-appearance"
fields={fields.filter((field) => field.key !== "minimumLevel")}
values={values}
disabled={disabled}
change={change}
/>
</div>
),
};
@@ -61,21 +64,27 @@ export function configValues(raw: string): ConfigValues {
}
}
export function configDefinition(raw: string): {
fields: ConfigNode[];
fields: ConfigField[];
settings: { color?: boolean; font?: boolean; when?: string[] };
description: string;
preset?: { style: string; reveal: string };
cache?: { identity: string; inputs: string[]; previousRevisions?: string[] };
renders?: AnnotationRender[];
} {
try {
const value = JSON.parse(raw);
return {
fields: value.fields ?? [],
description: value.description ?? "",
cache: value.cache,
renders: value.renders,
};
return Array.isArray(value)
? { fields: value, settings: {}, description: "" }
: {
fields: value.fields ?? [],
settings: value.settings ?? {},
description: value.description ?? "",
preset: value.preset,
cache: value.cache,
renders: value.renders,
};
} catch {
return { fields: [], description: "" };
return { fields: [], settings: {}, description: "" };
}
}
export const configFields = (raw: string) => configDefinition(raw).fields;
@@ -90,105 +99,91 @@ export function CustomConfig({
if (Custom) return <Custom fields={fields} values={values} change={change} disabled={disabled} />;
return (
<div className="lr-processor-settings">
{fields.map((field) =>
field.kind === "group" ? (
<fieldset key={field.key} className="lr-config-group">
<legend>{field.label}</legend>
<CustomConfig
processor={processor + "/" + field.key}
fields={field.fields}
values={
typeof values[field.key] === "object"
? (values[field.key] as ConfigValues)
: configDefaults(field.fields)
}
{fields.map((field) => (
<div key={field.key} className="lr-field">
<span>{field.label}</span>
{field.key === "fontFamily" ? (
<FontPicker
label={field.label}
value={String(values[field.key] ?? field.default)}
disabled={disabled}
change={(key, value) =>
change(field.key, {
...configDefaults(field.fields),
...(typeof values[field.key] === "object" ? (values[field.key] as ConfigValues) : {}),
[key]: value,
})
}
inherit
change={(value) => change(field.key, value)}
/>
</fieldset>
) : (
<div key={field.key} className="lr-field">
<span>{field.label}</span>
{field.presentation === "font" ? (
<FontPicker
label={field.label}
value={String(values[field.key] ?? field.default)}
disabled={disabled}
inherit
change={(value) => change(field.key, value)}
/>
) : field.kind === "select" ? (
<Options
label={field.label}
value={String(values[field.key] ?? field.default)}
options={(field.options ?? []).map((option) => ({
...option,
...(field.presentation === "underline"
? { preview: <UnderlinePreview pattern={option.value} /> }
: {}),
}))}
disabled={disabled}
change={(value) => change(field.key, value)}
/>
) : field.kind === "toggle" ? (
<Switch
label={field.label}
value={Boolean(values[field.key] ?? field.default)}
disabled={disabled}
change={(value) => change(field.key, value)}
/>
) : field.kind === "color" ? (
<ColorPicker
label={field.label}
value={String(values[field.key] ?? field.default)}
disabled={disabled}
change={(value) => change(field.key, value)}
/>
) : field.kind === "number" ? (
<NumberControl
label={field.label}
value={Number(values[field.key] ?? field.default)}
disabled={disabled}
min={field.min}
max={field.max}
change={(value) => change(field.key, value)}
/>
) : (
<LocalInput
aria-label={field.label}
value={String(values[field.key] ?? field.default)}
disabled={disabled}
onChange={(e) => change(field.key, e.target.value)}
/>
)}
</div>
),
)}
) : field.kind === "select" ? (
<Options
label={field.label}
value={String(values[field.key] ?? field.default)}
options={(field.options ?? []).map((option) => ({
...option,
...(field.key === "underlinePattern" ? { preview: <UnderlinePreview pattern={option.value} /> } : {}),
}))}
disabled={disabled}
change={(value) => change(field.key, value)}
/>
) : field.kind === "toggle" ? (
<Switch
label={field.label}
value={Boolean(values[field.key] ?? field.default)}
disabled={disabled}
change={(value) => change(field.key, value)}
/>
) : field.kind === "color" ? (
<ColorPicker
label={field.label}
value={String(values[field.key] ?? field.default)}
disabled={disabled}
change={(value) => change(field.key, value)}
/>
) : field.kind === "number" ? (
<NumberControl
label={field.label}
value={Number(values[field.key] ?? field.default)}
disabled={disabled}
min={field.min}
max={field.max}
change={(value) => change(field.key, value)}
/>
) : (
<LocalInput
aria-label={field.label}
value={String(values[field.key] ?? field.default)}
disabled={disabled}
onChange={(e) => change(field.key, e.target.value)}
/>
)}
</div>
))}
</div>
);
}
// Creation suggestions only: these are never injected into a built annotator.
export const fontFields: ConfigNode[] = [
{ key: "fontFamily", label: "Annotation font", kind: "text", presentation: "font", default: "" },
export const fontFields: ConfigField[] = [
{
key: "fontFamily",
label: "Annotation font",
kind: "select",
default: "",
options: [
{ value: "", label: "Match the reading text" },
{ value: '"Noto Serif", Georgia, serif', label: "Serif" },
{ value: "system-ui, sans-serif", label: "Sans serif" },
{ value: "ui-monospace, monospace", label: "Monospace" },
],
},
{ key: "fontSize", label: "Annotation font size", kind: "number", default: 13, min: 6, max: 72 },
];
export const underlineFields: ConfigNode[] = [
export const underlineFields: ConfigField[] = [
{
key: "underlinePattern",
label: "Underline pattern",
kind: "select",
presentation: "underline",
default: "solid",
options: ["solid", "dashed", "squiggly"].map((value) => ({
value,
label: value[0].toUpperCase() + value.slice(1),
})),
computation: false,
options: [
{ value: "solid", label: "Solid" },
{ value: "dashed", label: "Dashed" },
{ value: "squiggly", label: "Squiggly" },
],
},
];
+1 -14
View File
@@ -11,20 +11,7 @@ export function cacheConfiguration(
return JSON.stringify({
processor: identity,
scope,
options: Object.fromEntries(
[...inputs]
.sort()
.map((key) => [
key,
key
.split(".")
.reduce<unknown>(
(value, part) =>
value && typeof value === "object" ? (value as Record<string, unknown>)[part] : undefined,
options,
),
]),
),
options: Object.fromEntries([...inputs].sort().map((key) => [key, options[key]])),
});
}
-26
View File
@@ -1,26 +0,0 @@
import type { ConfigValue, ConfigValues } from "./config-schema.js";
export type OptionEdit = { path: string[]; value: ConfigValue };
/** A group component returns its struct; save only the leaves it actually changed. */
export function optionEdits(before: ConfigValue | undefined, after: ConfigValue, path: string[]): OptionEdit[] {
if (typeof after === "object") {
const previous = typeof before === "object" ? before : {};
return Object.entries(after).flatMap(([key, value]) => optionEdits(previous[key], value, [...path, key]));
}
return before === after ? [] : [{ path, value: after }];
}
/** Merge into the newest local snapshot, preserving edits to other controls. */
export function applyOptionEdits(values: ConfigValues, edits: OptionEdit[]): ConfigValues {
const result = structuredClone(values);
for (const { path, value } of edits) {
let group = result;
for (const key of path.slice(0, -1)) {
if (typeof group[key] !== "object") group[key] = {};
group = group[key] as ConfigValues;
}
group[path.at(-1)!] = value;
}
return result;
}
-40
View File
@@ -1,40 +0,0 @@
export type ConfigValue = string | number | boolean | ConfigValues;
export type ConfigValues = { [key: string]: ConfigValue };
export type ConfigField = {
key: string;
label: string;
kind: "color" | "number" | "select" | "toggle" | "text";
default: string | number | boolean;
min?: number;
max?: number;
computation?: boolean;
presentation?: "font" | "underline" | "position";
options?: { value: string; label: string }[];
};
export type ConfigGroup = { key: string; label: string; kind: "group"; fields: ConfigNode[] };
export type ConfigNode = ConfigField | ConfigGroup;
export function configDefaults(fields: ConfigNode[]): ConfigValues {
return Object.fromEntries(
fields.map((field) => [field.key, field.kind === "group" ? configDefaults(field.fields) : field.default]),
);
}
/** Include declared defaults inside partially saved groups, keeping other saved values. */
export function configWithDefaults(fields: ConfigNode[], saved: ConfigValues): ConfigValues {
const values = { ...configDefaults(fields), ...saved };
for (const field of fields) {
if (field.kind !== "group") continue;
const group = saved[field.key];
values[field.key] = configWithDefaults(field.fields, group && typeof group === "object" ? group : {});
}
return values;
}
export function configAt(values: ConfigValues, path: string): ConfigValues {
const result = path
? path
.split(".")
.reduce<
ConfigValue | undefined
>((value, key) => (value && typeof value === "object" ? value[key] : undefined), values)
: values;
return result && typeof result === "object" ? result : {};
}
+111 -52
View File
@@ -1,6 +1,3 @@
import { applyOptionEdits, optionEdits } from "./config-edits.js";
import { configDefaults, configWithDefaults } from "./config-schema.js";
import { resolveRenders } from "./render-hooks.js";
import { readerExtensionSettings } from "./reader-extensions.js";
import { usePrompt } from "./drafts.js";
import {
@@ -30,10 +27,10 @@ import { ReaderPreview } from "./preview.js";
import { interfaces } from "./gen/client.js";
import {
CustomConfig,
fontFields,
underlineFields,
configFields,
configValues,
fontFields,
underlineFields,
type ConfigValues,
type ConfigValue,
} from "./annotator-config.js";
@@ -465,7 +462,7 @@ export function ReaderSettings({
aria-current={section === referenceOf(f).object ? "page" : undefined}
onClick={() => setSection(referenceOf(f).object)}
>
<i className="lr-annotator-dot" />
<i style={{ background: f.data.color }} />
{f.data.name}
{!f.data.enabled && <small>Off</small>}
</button>
@@ -767,11 +764,10 @@ function AnnotatorLibrary({
const isArchived = (key: string) => archiveIntent[key] ?? archivedTypes.includes(key);
const item = catalog.find((candidate) => candidate.key === selected);
const definition = configDefinition(item?.config ?? "{}");
const channels = resolveRenders(definition.renders, values);
const style = channels[0]?.style ?? "color";
const style = definition.preset?.style ?? "highlight";
const choose = (candidate: Catalog[number]) => {
const fields = configDefinition(candidate.config).fields;
setValues(configDefaults(fields));
setValues(Object.fromEntries(fields.map((field) => [field.key, field.default])));
setTitle(candidate.label);
setSelected(candidate.key);
setError("");
@@ -804,6 +800,11 @@ function AnnotatorLibrary({
reader: { ...referenceOf(reader), expectation: { kind: "atom", id: "atom:reading:reader" } },
name: title.trim() || item.label,
processor: item.key,
style,
reveal: definition.preset?.reveal ?? "always",
generation: item.strategy,
scope: item.scope,
color: "#53796c",
options: JSON.stringify(values),
}),
),
@@ -854,13 +855,24 @@ function AnnotatorLibrary({
disabled={busy}
change={(key, value) => setValues((previous) => ({ ...previous, [key]: value }))}
/>
{style === "overhead" && (
<LabelPosition
value={String(values.placement ?? "above")}
disabled={busy}
change={(placement) => setValues((previous) => ({ ...previous, placement }))}
/>
)}
<footer>
<span>
{style === "overhead"
? "Text labels"
: (appearances.find((appearance) => appearance.key === style)?.title ?? style)}{" "}
·{" "}
{channels[0]?.reveal === "click" ? "On click" : channels[0]?.reveal === "hover" ? "On hover" : "Always"}
{definition.preset?.reveal === "click"
? "On click"
: definition.preset?.reveal === "hover"
? "On hover"
: "Always"}
</span>
<button className="lr-primary" disabled={busy || api.status !== "ready"} onClick={() => void add()}>
{busy ? "Adding…" : "Add to reader"}
@@ -878,11 +890,11 @@ function AnnotatorLibrary({
<article className="lr-type-card" key={candidate.key}>
<button className="lr-type-card-select" onClick={() => choose(candidate)}>
<span className="lr-type-mark" aria-hidden="true">
{config.renders?.[0]?.style === "overhead"
{config.preset?.style === "overhead"
? "Ā"
: config.renders?.[0]?.style === "sidebar"
: config.preset?.style === "sidebar"
? "☷"
: config.renders?.[0]?.style === "tooltip"
: config.preset?.style === "tooltip"
? "◌"
: "Aa"}
</span>
@@ -1182,11 +1194,7 @@ function NewAnnotator({
</div>
<footer className="lr-create-footer">
<small>Built and checked by the agent. A title is generated if left empty.</small>
<button
className="lr-primary"
disabled={busy || api.status !== "ready" || !text.trim()}
onClick={() => void build()}
>
<button className="lr-primary" disabled={busy || !text.trim()} onClick={() => void build()}>
{busy ? "Starting…" : "Create annotator"}
</button>
</footer>
@@ -1209,10 +1217,9 @@ export function ExistingFeature({
const [title, setTitle] = useState(d.name),
[enabledIntent, setEnabledIntent] = useState<boolean | null>(null),
[appearanceIntent, setAppearanceIntent] = useState<Partial<FeatureNode["data"]> | null>(null),
[draft, setDraft] = useState<{ values: ConfigValues; revision: number } | null>(null),
[saving, setSaving] = useState(false),
[error, setError] = useState("");
// Serialize only local projections; remote acknowledgements never block controls.
// Tickets belong to the replica and remain submitted when this editor unmounts.
const edits = useRef(Promise.resolve());
useEffect(() => setTitle(d.name), [d.name]);
function update(changes: Partial<typeof d>) {
if (ops.status !== "ready") return;
@@ -1221,14 +1228,16 @@ export function ExistingFeature({
feedback.track(ops.value.configure.call({ ...settings, ...changes, expectedRevision: configRevision }));
}
const definition = configDefinition(catalog.find((item) => item.key === d.processor)?.config ?? "{}"),
values = configWithDefaults(definition.fields, configValues(feature.options));
showOptions = revealOptions(d.style).filter((option) => definition.settings.when?.includes(option.value)),
persisted = configValues(feature.options),
values = draft?.values ?? persisted;
useEffect(() => {
onPreviewChange?.({
...feature,
data: { ...d, ...appearanceIntent, ...(enabledIntent === null ? {} : { enabled: enabledIntent }) },
options: JSON.stringify(values),
});
}, [feature, appearanceIntent, enabledIntent, onPreviewChange]);
}, [feature, appearanceIntent, enabledIntent, draft, onPreviewChange]);
useEffect(() => () => onPreviewChange?.(null), [onPreviewChange]);
useEffect(() => {
if (
@@ -1237,25 +1246,25 @@ export function ExistingFeature({
)
setAppearanceIntent(null);
}, [d, appearanceIntent]);
function change(key: string, value: ConfigValue) {
if (ops.status !== "ready") return;
const patch = optionEdits(values[key], value, [key]);
if (!patch.length) return;
const binding = ops.value;
const change = (key: string, value: ConfigValue) =>
setDraft((previous) => ({
revision: previous?.revision ?? d.configRevision,
values: { ...persisted, ...previous?.values, [key]: value },
}));
async function save() {
if (ops.status !== "ready" || !draft) return;
setSaving(true);
setError("");
edits.current = edits.current
.then(async () => {
const current = await binding.data.get(null);
const options = applyOptionEdits(configValues(await binding.options.get(null)), patch);
const ticket = feedback.track(
binding.configureOptions.call({
expectedRevision: current.configRevision,
options: JSON.stringify(options),
}),
);
await ticket.local;
})
.catch((error) => setError(errorMessage(error)));
try {
await accepted(
ops.value.configureOptions.call({ expectedRevision: draft.revision, options: JSON.stringify(draft.values) }),
);
setDraft(null);
} catch (e) {
setError(errorMessage(e));
} finally {
setSaving(false);
}
}
return (
<section className="lr-existing-row lr-settings-section">
@@ -1277,24 +1286,72 @@ export function ExistingFeature({
/>
</label>
<p className="lr-output-summary">
{resolveRenders(definition.renders, values)
.map(
(render) =>
`${appearances.find((a) => a.key === render.style)?.title ?? render.style} · ${render.reveal === "always" ? "Always" : render.reveal === "hover" ? "On hover" : "On click"}`,
)
.join("; ")}
{appearances.find((a) => a.key === d.style)?.title ?? d.style} ·{" "}
{d.reveal === "always" ? "Always shown" : d.reveal === "hover" ? "Shown when hovering" : "Shown when clicked"}
</p>
{showOptions.length > 0 && (
<div className="lr-field">
Show annotations
<Options
label={d.name + " show annotations"}
value={d.reveal}
options={showOptions.map((o) => ({
...o,
label: o.value === "always" ? "Always" : o.value === "hover" ? "When hovering" : "When clicked",
}))}
change={(reveal) => update({ reveal })}
/>
</div>
)}
{definition.settings.color && (
<div className="lr-field">
Annotation color
<ColorPicker label={d.name + " color"} value={d.color} change={(color) => update({ color })} />
</div>
)}
<CustomConfig
processor={d.processor}
fields={definition.fields}
values={values}
disabled={ops.status !== "ready"}
disabled={saving}
change={change}
/>
{definition.fields.length > 0 && (
<small className="lr-config-status" role="status" aria-live="polite">
{feedback.pending > 0 ? "Saving…" : "Changes save automatically."}
</small>
{(d.style === "underline" || definition.renders?.some((render) => render.style === "underline")) && (
<CustomConfig
processor="underline-pattern"
fields={underlineFields}
values={values}
disabled={saving}
change={change}
/>
)}
{definition.settings.font && d.style === "overhead" && (
<>
{!definition.fields.some((field) => field.key === "placement") && (
<LabelPosition
value={String(values.placement ?? "above")}
disabled={saving}
change={(placement) => change("placement", placement)}
/>
)}
<CustomConfig
processor="overhead-font"
fields={fontFields}
values={values}
disabled={saving}
change={change}
/>
</>
)}
{draft && (
<div className="lr-config-save">
<button className="lr-primary" disabled={saving} onClick={() => void save()}>
{saving ? "Saving…" : "Save annotation options"}
</button>
<button disabled={saving} onClick={() => setDraft(null)}>
Cancel
</button>
</div>
)}
{(error || feedback.message) && <p role="alert">{error || feedback.message}</p>}
<div className="lr-annotator-footer">
@@ -1344,6 +1401,8 @@ function AskForChange({
reader: { ...referenceOf(reader), expectation: { kind: "atom", id: "atom:reading:reader" } },
configuration: JSON.stringify({
name: feature.data.name,
style: feature.data.style,
reveal: feature.data.reveal,
instructions: text,
feature: referenceOf(feature),
}),
-4
View File
@@ -58,7 +58,3 @@ export async function accepted<T>(ticket: { settled: Promise<{ status: string; v
if (outcome.status !== "accepted") throw Error(JSON.stringify(outcome));
return outcome.value as T;
}
export type ProcessingFeature = FeatureNode & {
data: FeatureNode["data"] & { generation: string; scope: string; color: string };
};
+10 -10
View File
@@ -1,4 +1,3 @@
import { configDefaults } from "./config-schema.js";
import { useMemo } from "react";
import { referenceOf, type ExactReference } from "@quixos/camino-react";
import { type Catalog, type DocNode, type FeatureNode, type ReaderNode } from "./model.js";
@@ -65,13 +64,13 @@ export function ReaderPreview({
if (!entry) continue;
const definition = configDefinition(entry.config);
const options = {
...configDefaults(definition.fields),
...Object.fromEntries(definition.fields.map((field) => [field.key, field.default])),
...configValues(feature.options),
};
const configuration = cacheConfiguration(
definition.cache?.identity ?? feature.data.processor,
entry.scope,
entry.strategy,
feature.data.scope,
feature.data.generation,
options,
definition.cache?.inputs ?? [],
);
@@ -109,14 +108,15 @@ export function ReaderPreview({
id: `${referenceOf(feature).object}:${job.data.key}:${index}`,
feature: referenceOf(feature).object,
activation: { start: job.data.start, end: job.data.end },
priority: (entry.scope === "word" ? 0 : entry.scope === "sentence" ? 1 : 2) * features.length + order,
style: item.style ?? "color",
reveal: item.reveal ?? "always",
color: item.color ?? "",
priority:
(feature.data.scope === "word" ? 0 : feature.data.scope === "sentence" ? 1 : 2) * features.length +
order,
style: item.style ?? (feature.data.style as Annotation["style"]),
reveal: item.reveal ?? (feature.data.reveal as Annotation["reveal"]),
color: item.color ?? feature.data.color,
},
text,
item.style ? [{ style: item.style, reveal: item.reveal ?? "always" }] : definition.renders,
options,
item.style ? undefined : definition.renders,
),
);
}
+10 -13
View File
@@ -2,12 +2,11 @@ import { useEffect, useRef, useState } from "react";
import { referenceOf, useInterface } from "@quixos/camino-react";
import { interfaces } from "./gen/client.js";
import { sameCacheConfiguration } from "./cache.js";
import { accepted, errorMessage, type DocNode, type ProcessingFeature as FeatureNode } from "./model.js";
import { accepted, errorMessage, type DocNode, type FeatureNode } from "./model.js";
import { preparationQueue, processorMatches, sameScope } from "./work.js";
import type { Range } from "./canvas/types.js";
import type { ReadingWindow } from "./canvas/pages.js";
import { resolveRenders, type AnnotationRender } from "./render-hooks.js";
import { configValues } from "./annotator-config.js";
import type { AnnotationRender } from "./render-hooks.js";
type Planned = { feature: FeatureNode; range: Range; context: string; planning?: "click" | "hover" };
/** One admission/cache path for proximity preparation, clicks, hover and retry.
@@ -79,7 +78,7 @@ export function useProcessing({
range.start,
range.end,
].join(":");
function request(feature: FeatureNode, range: Range, retry = false, settled?: () => void): boolean {
function request(feature: FeatureNode, range: Range, retry = false): boolean {
if (!annotationsReady || ops.status !== "ready" || range.end <= range.start || !revisions[feature.data.processor])
return false;
const item = { feature, range, context },
@@ -95,14 +94,12 @@ export function useProcessing({
retry,
createdAt: new Date().toISOString(),
}),
)
.catch((cause) => {
admitting.current.delete(key);
setIntent((previous) => previous.filter((item) => item.context !== context || keyFor(item) !== key));
error(errorMessage(cause));
setTick((value) => value + 1);
})
.finally(settled);
).catch((cause) => {
admitting.current.delete(key);
setIntent((previous) => previous.filter((item) => item.context !== context || keyFor(item) !== key));
error(errorMessage(cause));
setTick((value) => value + 1);
});
return true;
}
useEffect(() => {
@@ -192,7 +189,7 @@ export function useProcessing({
(f) =>
f.data.enabled &&
f.data.generation === "focus" &&
resolveRenders(renders[f.data.processor], configValues(f.options)).some((render) =>
(renders[f.data.processor] ?? [{ reveal: f.data.reveal }]).some((render) =>
kind === "hover" ? render.reveal === "hover" : render.reveal !== "hover",
),
);
+7 -38
View File
@@ -1,52 +1,21 @@
import type { Annotation } from "./canvas/types.js";
import { configAt, type ConfigValues } from "./config-schema.js";
/** Each channel is code-owned. Options explicitly binds an appearance struct;
* several channels can use different structs from the same annotator config. */
/** Several views of one cached semantic item. These are authored presentation
* choices, not separate computations or independently generated annotations. */
export type AnnotationRender = {
style: Annotation["style"];
underlinePattern?: Annotation["underlinePattern"];
placement?: Annotation["placement"];
color?: string;
fontFamily?: string;
fontSize?: number;
options?: string;
reveal: "always" | "click" | "hover";
content?: "result" | "source";
};
export function resolveRenders(renders: readonly AnnotationRender[] | undefined, values: ConfigValues = {}) {
return (renders ?? []).map((render) => {
const bound = render.options === undefined ? {} : configAt(values, render.options);
const output = { ...render };
for (const key of ["color", "fontFamily", "fontSize", "placement", "underlinePattern", "reveal"] as const)
if (bound[key] !== undefined) Object.assign(output, { [key]: bound[key] });
if (
!["always", "click", "hover"].includes(output.reveal) ||
(output.reveal === "hover" && ["overhead", "replace", "bold", "italic"].includes(output.style)) ||
(output.style === "tooltip" && output.reveal === "always") ||
(output.underlinePattern && !["solid", "dashed", "squiggly"].includes(output.underlinePattern)) ||
(output.placement && !["above", "below"].includes(output.placement)) ||
(output.color && !/^#[0-9a-f]{6}$/i.test(output.color)) ||
(output.fontFamily && (output.fontFamily.length > 160 || /[\r\n\x00]/.test(output.fontFamily))) ||
(output.fontSize !== undefined &&
(!Number.isFinite(output.fontSize) || output.fontSize < 6 || output.fontSize > 72))
)
throw Error("Invalid annotator render channel");
return output;
});
}
export function renderAnnotations(
item: Annotation,
source: string,
renders: readonly AnnotationRender[] | undefined,
values: ConfigValues = {},
) {
if (!renders?.length) return [];
return resolveRenders(renders, values).map(
export function renderAnnotations(item: Annotation, source: string, renders: readonly AnnotationRender[] | undefined) {
if (!renders?.length) return [item];
return renders.map(
(render, index): Annotation => ({
...item,
...render,
id: `${item.id}:render:${index}`,
color: item.color || render.color || "#66866b",
...render,
text: render.content === "source" ? source.slice(item.start, item.end) : item.text,
}),
);
+1 -5
View File
@@ -1,5 +1,4 @@
export const readingStyle = `
.lr-config-group{border:1px solid #d9dfd6;border-radius:12px;padding:18px;margin:10px 0 20px;min-width:0}.lr-config-group legend{padding:0 8px;font-weight:600;color:#354b3f}.lr-config-group .lr-processor-settings{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:18px}.lr-annotator-dot{background:#66866b}
.lr-app{--ink:#303a36;--muted:#7c827b;--line:#e6e6de;--green:#3e6957;position:relative;display:flex;flex:0 1 auto;height:calc(100dvh - var(--reader-top,120px));max-height:100%;min-height:0;background:#fafaf6;color:var(--ink);font:14px/1.5 system-ui,sans-serif;overflow:hidden}
.lr-app *{box-sizing:border-box}
@@ -133,7 +132,7 @@ export const readingStyle = `
.lr-preview-color b{color:var(--annotation)}
.lr-processor-settings{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0 16px}
.lr-processor-settings input[type=checkbox]{width:auto;align-self:flex-start}
.lr-config-status{display:block;margin:12px 0;color:var(--muted)}
.lr-config-save{display:flex;gap:8px;margin:12px 0}
.lr-page{max-width:none}
.reading-pagination{display:flex;align-items:center;justify-content:center;gap:18px;padding:10px 24px;border-top:1px solid var(--line);font-size:12px}.reading-pagination button{font-size:11px}.lr-view-mode{width:100px}
@@ -217,9 +216,6 @@ export const readingStyle = `
.lr-request-config dt{color:var(--muted)}.lr-request-config dd{margin:0}
.lr-request-config p{font-size:13px;white-space:pre-wrap}
.lr-variant-status{padding:12px 24px;font-size:12px;border-top:1px solid var(--line);background:#f5f7f0;display:flex;align-items:center;gap:12px}
.lr-annotation-retries{padding:8px 24px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}
.lr-annotation-retry{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:4px 0}
.lr-annotation-retry button{font-size:12px;white-space:nowrap}
@media(max-width:650px){.lr-settings-nav{width:145px;padding:12px 6px}.lr-settings-content{padding:18px 14px}.lr-settings-top{padding:8px 14px}.lr-settings-nav button{font-size:12px}.lr-settings-content .lr-fields{flex-direction:column;gap:0}}
+16 -92
View File
@@ -1,11 +1,10 @@
import { configDefaults } from "./config-schema.js";
import { Texts } from "./texts.js";
import { cacheConfiguration, sameCacheConfiguration } from "./cache.js";
import { configDefinition, configValues } from "./annotator-config.js";
import { presentAnnotations } from "./annotation-display.js";
import { Options } from "./controls.js";
import { Button, Choice, SegmentedControl, useCommandControl } from "@quixos/camino-react/ui";
import { renderAnnotations, resolveRenders, type AnnotationRender } from "./render-hooks.js";
import { Choice, SegmentedControl, useCommandControl } from "@quixos/camino-react/ui";
import { renderAnnotations, type AnnotationRender } from "./render-hooks.js";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
referenceOf,
@@ -27,14 +26,7 @@ import { intersects, type Annotation, type Range } from "./canvas/types.js";
import type { ReadingWindow } from "./canvas/pages.js";
import type { ProcessingRegion } from "./canvas/processing.js";
import { useProcessing } from "./processing.js";
import {
HoverIntent,
processorMatches,
selectSuccessfulAnnotationJobs,
selectAnnotationRetryJobs,
annotationRetryStatus,
explicitAnnotationRetry,
} from "./work.js";
import { HoverIntent, processorMatches, selectSuccessfulAnnotationJobs } from "./work.js";
import { ReaderSettings } from "./editor.js";
import { Importer, SpeechSettings, Playback, Rewrite } from "./sources.js";
import {
@@ -113,15 +105,15 @@ export const Reader: Modules["Reader"] = ({ subject, navigation, input }) => {
const entry = catalog.find((item) => item.key === feature.data.processor);
const definition = configDefinition(entry?.config ?? "{}");
const options = {
...configDefaults(definition.fields),
...Object.fromEntries(definition.fields.map((field) => [field.key, field.default])),
...configValues(feature.options),
};
return [
referenceOf(feature).object,
cacheConfiguration(
definition.cache?.identity ?? feature.data.processor,
entry?.scope ?? "word",
entry?.strategy ?? "section",
feature.data.scope,
feature.data.generation,
options,
definition.cache?.inputs ?? [],
),
@@ -256,7 +248,6 @@ export const Reader: Modules["Reader"] = ({ subject, navigation, input }) => {
revisionAliases={revisionAliases}
computations={computations}
renders={renders}
catalog={catalog}
reader={activeReader}
onOpen={open}
navigation={navigation.reference}
@@ -321,7 +312,6 @@ function ReadingDocument({
revisionAliases,
computations,
renders,
catalog,
reader,
onOpen,
navigation,
@@ -341,7 +331,6 @@ function ReadingDocument({
revisionAliases: Record<string, readonly string[]>;
computations: Record<string, string>;
renders: Record<string, AnnotationRender[] | undefined>;
catalog: Catalog;
reader?: ReaderNode;
onOpen: (ref: ExactReference) => void;
navigation: ExactReference;
@@ -434,32 +423,14 @@ function ReadingDocument({
}
const features = useMemo(() => {
const members = new Set(reader?.features.entries.map((e) => referenceOf(e.node).object) ?? []);
return root.features.entries
.map((e) => e.node)
.filter((f) => members.has(referenceOf(f).object))
.flatMap((feature) => {
const entry = catalog.find((item) => item.key === feature.data.processor);
if (!entry) return [];
const channels = resolveRenders(renders[feature.data.processor], configValues(feature.options));
return [
{
...feature,
data: {
...feature.data,
generation: entry.strategy,
scope: entry.scope,
color: channels[0]?.color ?? "#66866b",
},
},
];
});
}, [root.features.entries, reader, catalog, renders]),
return root.features.entries.map((e) => e.node).filter((f) => members.has(referenceOf(f).object));
}, [root.features.entries, reader]),
jobs = useMemo(() => doc.jobs.entries.map(({ node }) => node), [doc.jobs.entries]);
const enabled = features.filter((feature) => feature.data.enabled);
const displays = enabled.flatMap((feature) =>
resolveRenders(renders[feature.data.processor], configValues(feature.options)).map((render) => ({
(renders[feature.data.processor] ?? [{ style: feature.data.style, reveal: feature.data.reveal }]).map((render) => ({
...feature,
data: { ...feature.data, style: render.style, reveal: render.reveal, color: render.color ?? feature.data.color },
data: { ...feature.data, style: render.style, reveal: render.reveal },
})),
);
const model = useProcessing({
@@ -609,13 +580,12 @@ function ReadingDocument({
priority:
(feature.data.scope === "word" ? 0 : feature.data.scope === "sentence" ? 1 : 2) * features.length +
features.indexOf(feature),
style: item.style ?? "color",
reveal: item.reveal ?? "always",
color: item.color ?? "",
style: item.style ?? (feature.data.style as Annotation["style"]),
reveal: item.reveal ?? (feature.data.reveal as Annotation["reveal"]),
color: item.color ?? feature.data.color,
},
doc.data.text,
item.style ? [{ style: item.style, reveal: item.reveal ?? "always" }] : renders[feature.data.processor],
configValues(feature.options),
item.style ? undefined : renders[feature.data.processor],
),
);
}
@@ -627,7 +597,7 @@ function ReadingDocument({
const annotation = annotationMap.find((item) => item.id === id),
feature = features.find((feature) => referenceOf(feature).object === annotation?.feature);
if (!annotation || !feature) return false;
const reveal = annotation.reveal ?? "always";
const reveal = annotation.reveal ?? feature.data.reveal;
if (reveal === "click" && feature.data.generation === "focus" && feature.data.scope !== "word")
return (
!!focus &&
@@ -695,22 +665,6 @@ function ReadingDocument({
)
.map((job) => ({ feature, job })),
);
const retryJobs = selectAnnotationRetryJobs(
jobs,
enabled.map((feature) => ({
feature,
configuration: computations[referenceOf(feature).object],
revision: revisions[feature.data.processor],
aliases: revisionAliases[feature.data.processor],
styles: resolveRenders(renders[feature.data.processor], configValues(feature.options)).map(
(render) => render.style,
),
})),
doc.data.sourceRevision,
window.visible,
);
const retryInFlight = useRef(new Set<string>());
const [, updateRetryState] = useState(0);
const pendingSidebars = displays
.filter((feature) => feature.data.style === "sidebar")
.flatMap((feature) =>
@@ -927,7 +881,7 @@ function ReadingDocument({
return {
...item,
label: feature.data.name,
trigger: item.reveal as "hover" | "click",
trigger: (item.reveal ?? feature.data.reveal) as "hover" | "click",
};
}),
...pendingTooltips,
@@ -951,36 +905,6 @@ function ReadingDocument({
Loading annotations…
</div>
)}
{retryJobs.length > 0 && (
<div className="lr-annotation-retries" aria-label="Annotation preparation">
{retryJobs.map(({ feature, job }) => {
const key = referenceOf(job).object;
return (
<div className="lr-annotation-retry" key={key}>
<span role="status">
<strong>{feature.data.name}</strong>: {annotationRetryStatus(job.data.status, job.data.error)}
</span>
<Button
size="compact"
tone="quiet"
disabled={!annotationsReady || retryInFlight.current.has(key)}
aria-label={`Try again: ${feature.data.name}`}
onClick={() =>
explicitAnnotationRetry(
key,
retryInFlight.current,
(settled) => request(feature, { start: job.data.start, end: job.data.end }, true, settled),
() => updateRetryState((value) => value + 1),
)
}
>
{retryInFlight.current.has(key) ? "Trying again…" : "Try again"}
</Button>
</div>
);
})}
</div>
)}
{variant && next && (
<div className="lr-variant-status" role="status">
<span>
+1 -81
View File
@@ -1,5 +1,5 @@
import { sameCacheConfiguration } from "./cache.js";
import { intersects, type Range } from "./canvas/types.js";
import type { Range } from "./canvas/types.js";
export type Feature = {
processor: string;
generation: string;
@@ -37,86 +37,6 @@ type SemanticJob = {
createdAt: string;
};
};
export type AnnotationRetryFeature<F> = {
feature: F;
configuration: string;
revision: string;
aliases?: readonly string[];
styles: readonly string[];
};
/** Nearby controls describe the newest attempt for a semantic scope, not its
* history. Shared renderer instances and verified processor aliases must not
* multiply controls. Sidebar computations already have their own controls. */
export function selectAnnotationRetryJobs<F, J extends SemanticJob>(
jobs: readonly J[],
features: readonly AnnotationRetryFeature<F>[],
sourceRevision: string,
visible: Range,
) {
const scopes: { configuration: string; start: number; end: number; revision: string }[] = [];
const result: { feature: F; job: J }[] = [];
if (visible.end <= visible.start) return result;
for (const job of [...jobs].sort((a, b) => b.data.createdAt.localeCompare(a.data.createdAt))) {
if (job.data.kind !== "annotation" || job.data.sourceRevision !== sourceRevision || !intersects(job.data, visible))
continue;
const matching = features.filter(
(feature) =>
feature.revision &&
processorMatches(job.data, feature.revision, feature.aliases) &&
sameCacheConfiguration(feature.configuration, job.configuration),
);
const feature = matching.find((feature) => feature.styles.some((style) => style !== "sidebar"));
if (!feature || matching.some((feature) => feature.styles.includes("sidebar"))) continue;
if (
scopes.some(
(scope) =>
scope.revision === feature.revision &&
scope.start === job.data.start &&
scope.end === job.data.end &&
sameCacheConfiguration(scope.configuration, job.configuration),
)
)
continue;
scopes.push({
configuration: job.configuration,
start: job.data.start,
end: job.data.end,
revision: feature.revision,
});
if (["failed", "skipped", "uncertain"].includes(job.data.status)) result.push({ feature: feature.feature, job });
}
return result;
}
export function annotationRetryStatus(status: string, error: string) {
if (error.includes("AGENT_SETUP_UNAVAILABLE")) return "Setup was unavailable. Try again when connected.";
if (status === "skipped") return "Not prepared. Try again when ready.";
if (status === "uncertain") return "Preparation could not be confirmed.";
return "Could not prepare annotations.";
}
/** Only an explicit control invokes this dispatcher. Guard double clicks until
* the public operation settles; neither selection nor a status update retries. */
export function explicitAnnotationRetry(
key: string,
inFlight: Set<string>,
dispatch: (settled: () => void) => boolean,
changed: () => void,
) {
if (inFlight.has(key)) return false;
inFlight.add(key);
changed();
const settled = () => {
inFlight.delete(key);
changed();
};
try {
if (dispatch(settled)) return true;
settled();
return false;
} catch (error) {
settled();
throw error;
}
}
/** A successful task replaces its whole semantic scope, including omitted
* annotations. Deduplicating individual items resurrects removed old output. */
export function selectSuccessfulAnnotationJobs<T extends SemanticJob>(
+1 -264
View File
@@ -1,5 +1,3 @@
import { configWithDefaults, type ConfigNode, type ConfigValues } from "../src/config-schema.js";
import { applyOptionEdits, optionEdits } from "../src/config-edits.js";
import assert from "node:assert/strict";
import {
HoverIntent,
@@ -8,9 +6,6 @@ import {
preparationQueue,
selectSuccessfulAnnotationJobs,
mergeCachedAnnotationEntries,
selectAnnotationRetryJobs,
annotationRetryStatus,
explicitAnnotationRetry,
} from "../src/work.js";
const oldProcessor = { key: "processor-v1:scope" };
assert(processorMatches(oldProcessor, "processor-v1"));
@@ -151,7 +146,7 @@ assert.deepEqual(
assert.equal(new Set(outputs.map((output) => output.id)).size, 3);
assert(outputs.every((output) => output.feature === semantic.feature && output.start === 6 && output.end === 9));
assert.equal(semantic.text, "word", "Rendering never mutates cached semantic data");
assert.deepEqual(renderAnnotations(semantic, source, undefined), [], "Missing channels never invent an appearance");
assert.deepEqual(renderAnnotations(semantic, source, undefined), [semantic]);
console.log("PASS one source rendered as replacement, color and original-text hover tooltip");
assert(
@@ -261,261 +256,3 @@ assert.deepEqual(
console.log(
"PASS canonical cache pointers and whole-scope replacement, including empty/narrowed successes, aliases and failed retries",
);
const retryFeature = {
feature: "pinyin",
configuration: scopeConfiguration,
revision: "current",
aliases: ["verified-previous"],
styles: ["overhead"],
};
const retryAttempt = (object: string, status: string, start = 0, createdAt = "2026-10-04") => ({
...savedJob(object, createdAt, []),
data: { ...savedJob(object, createdAt, []).data, status, start, end: start + 6 },
});
const skipped = retryAttempt("skipped", "skipped"),
failed = retryAttempt("failed", "failed", 6),
uncertain = retryAttempt("uncertain", "uncertain", 12),
offPage = retryAttempt("off-page", "skipped", 30),
ready = retryAttempt("ready", "ready", 18),
queued = retryAttempt("queued", "queued", 24);
const retryHistory = [skipped, failed, uncertain, offPage, ready, queued];
const retryHistorySnapshot = structuredClone(retryHistory);
const visiblePage = { start: 0, end: 30 };
assert.deepEqual(
selectAnnotationRetryJobs(
retryHistory,
[retryFeature, { ...retryFeature, feature: "hover pinyin", styles: ["tooltip"] }],
"doc@1",
visiblePage,
).map(({ feature, job }) => [feature, job._camino.ref.object]),
[
["pinyin", "skipped"],
["pinyin", "failed"],
["pinyin", "uncertain"],
],
"Failed/skipped/uncertain visible scopes appear once across shared annotators; queued/ready/off-page scopes do not",
);
const legacySkipped = {
...skipped,
configuration: oldAlias.configuration,
data: { ...skipped.data, key: "verified-previous:legacy", createdAt: "2026-10-03" },
};
assert.equal(
selectAnnotationRetryJobs([legacySkipped, skipped], [retryFeature], "doc@1", visiblePage).length,
1,
"Verified aliases and legacy scheduling configuration deduplicate the same scope",
);
for (const status of ["ready", "queued"]) {
assert.deepEqual(
selectAnnotationRetryJobs(
[skipped, legacySkipped, retryAttempt("replacement", status, 0, "2026-10-05")],
[retryFeature],
"doc@1",
visiblePage,
),
[],
"Newer queued or successful work supersedes old errors",
);
}
assert.deepEqual(
selectAnnotationRetryJobs([skipped], [{ ...retryFeature, styles: ["sidebar"] }], "doc@1", visiblePage),
[],
);
assert.deepEqual(
selectAnnotationRetryJobs([skipped], [{ ...retryFeature, styles: ["underline", "sidebar"] }], "doc@1", visiblePage),
[],
"Multi-hook sidebar processors already have controls",
);
assert.deepEqual(
selectAnnotationRetryJobs(
[skipped],
[retryFeature, { ...retryFeature, feature: "notes", styles: ["sidebar"] }],
"doc@1",
visiblePage,
),
[],
"A shared sidebar renderer does not get duplicate controls",
);
assert.deepEqual(selectAnnotationRetryJobs([skipped], [retryFeature], "doc@2", visiblePage), []);
assert.deepEqual(selectAnnotationRetryJobs([skipped], [retryFeature], "doc@1", { start: 0, end: 0 }), []);
assert.deepEqual(
selectAnnotationRetryJobs(
[{ ...skipped, data: { ...skipped.data, kind: "variant" } }],
[retryFeature],
"doc@1",
visiblePage,
),
[],
);
assert.deepEqual(
selectAnnotationRetryJobs(
[{ ...unknown, data: { ...unknown.data, status: "failed" } }],
[retryFeature],
"doc@1",
visiblePage,
),
[],
);
assert.deepEqual(
selectAnnotationRetryJobs(
[{ ...anotherInput, data: { ...anotherInput.data, status: "failed" } }],
[retryFeature],
"doc@1",
visiblePage,
),
[],
);
assert.deepEqual(
retryHistory,
retryHistorySnapshot,
"Status selection preserves all cached values and attempt history",
);
assert.equal(
annotationRetryStatus("skipped", "AGENT_SETUP_UNAVAILABLE: Authentication required"),
"Setup was unavailable. Try again when connected.",
);
assert.equal(annotationRetryStatus("uncertain", "raw transport error"), "Preparation could not be confirmed.");
const retrying = new Set<string>();
let explicitCalls = 0,
finishRetry: () => void = () => {},
rerenders = 0;
const dispatch = (settled: () => void) => {
explicitCalls++;
// The view delegates the original scope with retry:true, without replanning.
const { feature, job } = selectAnnotationRetryJobs([skipped], [retryFeature], "doc@1", visiblePage)[0];
assert.deepEqual(
{ feature, start: job.data.start, end: job.data.end, retry: true },
{ feature: "pinyin", start: 0, end: 6, retry: true },
);
finishRetry = settled;
return true;
};
assert.equal(explicitCalls, 0, "Selecting failed statuses never dispatches inference");
assert(explicitAnnotationRetry("skipped", retrying, dispatch, () => rerenders++));
assert(!explicitAnnotationRetry("skipped", retrying, dispatch, () => rerenders++));
assert.equal(explicitCalls, 1, "Double clicks cannot repeat an in-flight retry");
finishRetry();
assert.equal(explicitCalls, 1, "Settlement never automatically retries");
assert.equal(retrying.size, 0);
assert.equal(rerenders, 2);
assert(
!explicitAnnotationRetry(
"not-ready",
retrying,
() => false,
() => {},
),
);
assert.equal(retrying.size, 0, "An unavailable operation releases the button");
assert.throws(() =>
explicitAnnotationRetry(
"rejected",
retrying,
() => {
throw Error("rejected");
},
() => {},
),
);
assert.equal(retrying.size, 0);
console.log(
"PASS explicit nearby annotator retries, shared identities/aliases, sidebar/off-page exclusion and superseded attempts",
);
// Two channels use independently declared structs, not a single feature type.
import { configDefaults } from "../src/config-schema.js";
const raw = { ...semantic, color: "" };
const multi = renderAnnotations(
raw,
source,
[
{ style: "underline", reveal: "always", options: "primary" },
{ style: "underline", reveal: "always", options: "secondary" },
{ style: "replace", reveal: "click" },
{ style: "tooltip", reveal: "hover", content: "source" },
],
{
primary: { color: "#112233", underlinePattern: "squiggly" },
secondary: { color: "#445566", underlinePattern: "dashed" },
},
);
assert.deepEqual(
multi.slice(0, 2).map((a) => [a.color, a.underlinePattern]),
[
["#112233", "squiggly"],
["#445566", "dashed"],
],
);
const changed = renderAnnotations(raw, source, [{ style: "underline", reveal: "always", options: "primary" }], {
primary: { color: "#112233", underlinePattern: "solid" },
});
assert.equal(changed[0].underlinePattern, "solid");
assert.equal(raw.text, "word");
assert.throws(
() =>
renderAnnotations(raw, source, [{ style: "overhead", reveal: "always", options: "label" }], {
label: { fontSize: 500 },
}),
/Invalid annotator render channel/,
);
assert.throws(
() => renderAnnotations(raw, source, [{ style: "overhead", reveal: "hover" }]),
/Invalid annotator render channel/,
);
assert.deepEqual(
configDefaults([
{
kind: "group",
key: "underlines",
label: "Underlines",
fields: [{ kind: "select", key: "pattern", label: "Pattern", default: "dashed" }],
},
]),
{ underlines: { pattern: "dashed" } },
);
console.log("PASS code-owned settings bind independent render channels without replacing semantic data");
// Two group controls may fire from the same render before either query update.
const shown = { label: { fontSize: 13, fontFamily: "serif", color: "#112233" } };
const sizeEdit = optionEdits(shown.label, { ...shown.label, fontSize: 20 }, ["label"]);
const fontEdit = optionEdits(shown.label, { ...shown.label, fontFamily: "monospace" }, ["label"]);
const concurrent = { ...shown, label: { ...shown.label, color: "#334455" } };
assert.deepEqual(
applyOptionEdits(applyOptionEdits(concurrent, sizeEdit), fontEdit),
{
label: { fontSize: 20, fontFamily: "monospace", color: "#334455" },
},
"Queued group edits preserve each other and a concurrent change to another leaf",
);
assert.equal(shown.label.fontSize, 13, "Applying an edit never mutates the displayed snapshot");
// New controls can have defaults without their values being stored yet.
const labelFields: ConfigNode[] = [
{
kind: "group",
key: "label",
label: "Label",
fields: [
{ kind: "number", key: "fontSize", label: "Size", default: 13 },
{ kind: "text", key: "fontFamily", label: "Font", default: "serif" },
{ kind: "color", key: "color", label: "Color", default: "#112233" },
],
},
];
const sparseOptions: ConfigValues[] = [{}, { label: { color: "#556677" } }];
for (const persisted of sparseOptions) {
const effective = configWithDefaults(labelFields, persisted);
const label = effective.label as ConfigValues;
const size = optionEdits(label, { ...label, fontSize: 20 }, ["label"]);
const font = optionEdits(label, { ...label, fontFamily: "monospace" }, ["label"]);
const latest = { label: { color: "#abcdef" }, unrelated: "preserved" };
assert.deepEqual(
applyOptionEdits(applyOptionEdits(latest, size), font),
{
label: { color: "#abcdef", fontSize: 20, fontFamily: "monospace" },
unrelated: "preserved",
},
"Defaults in sparse groups must not undo queued edits or a concurrent color change",
);
}