textmachine/frontend/src/api/vocabulary.ts

200 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Closed wire vocabularies, and what each value MEANS to the user. One file per the ratified
// answer to the owner's control question (companion §5): "a new product state edits one file —
// the value → appearance map on the `src/api/` seam".
//
// Two things live here together on purpose. The list of values, because the seam has to narrow a
// value that came off the wire; and the product word, because splitting them means a new status
// is added in one file and stays unnamed in another.
//
// ⚠ The unknown branch is the point of this file, not a nicety. Generated types turn `enum` into
// a CLOSED union, a minor bump may legally add a value, and the typechecker does not catch it:
// indexing a `Record` by a finite union compiles silently, so `runStates[book.status]` returns
// `undefined` and the tree dies on `undefined.tone` (BACKLOG Ф-22).
import type { MessageKey } from '../i18n/text';
import type { components } from './schema';
type Schemas = components['schemas'];
/**
* Colour is the second channel: four steps, and every one of them is a STATE — work in progress,
* finished, waiting on a person, refused. Never decoration.
*/
export type Tone = 'note' | 'ok' | 'warn' | 'danger';
export interface Vocabulary<Value extends string, Meaning> {
/** The values of version 0.x. Derived from the entries, so the two cannot drift. */
readonly values: readonly Value[];
/** Narrow a value that came off the wire. An unrecognized one becomes `null`, never a throw. */
read(value: string): Value | null;
/** What the value means, including the branch for a value this build does not know. */
describe(value: Value | null): Meaning;
}
// `Record<Value, Meaning>` is what makes this exhaustive: a value added to the spec fails the
// typecheck here until it is given a meaning, which is the whole mechanism.
function vocabulary<Value extends string, Meaning>(
entries: Readonly<Record<Value, Meaning>>,
unknown: Meaning,
): Vocabulary<Value, Meaning> {
const values = Object.keys(entries) as Value[];
return {
values,
read: (value) => (Object.hasOwn(entries, value) ? (value as Value) : null),
describe: (value) => (value === null ? unknown : entries[value]),
};
}
/**
* Product status of a book. The label is a WORD, not a dot: nine states could not be told apart
* by shape and tone alone — a probe put three pairs on the same pixels — so the word carries the
* meaning and colour only doubles it, for attention and refusal.
*/
export const bookStatus = vocabulary<
Schemas['BookStatus'],
{ label: MessageKey; tone?: Tone; startable?: boolean; intake?: boolean }
>(
{
// `intake` — the book is still ARRIVING: it has no run, therefore no event stream, therefore
// no way of announcing that it is over. The reads poll while it lasts (src/api/queries.ts).
uploading: { label: 'status.uploading', tone: 'note', intake: true },
parsing: { label: 'status.parsing', tone: 'note', intake: true },
not_started: { label: 'status.notStarted', startable: true },
translating: { label: 'status.translating', tone: 'note' },
awaiting_bank: { label: 'status.awaitingBank', tone: 'warn' },
finalizing: { label: 'status.finalizing', tone: 'note' },
ready: { label: 'status.ready', tone: 'ok', startable: true },
// Resumable stop, so no danger tone: the screen must not read as a failure (D39.100 K-8).
// ⚠ The wording is the OWNER'S and is not ours to shorten (В-6). S3.5 did shorten it to fit
// the badge slot and had to put it back: the badge truncates with a tooltip instead, and
// which word belongs in the tree is a question for the owner, not a layout decision.
//
// Startable, and that is not the same as resumable: a run stopped by a ceiling is not moved by
// `resume` at all (contract §resumeRun), while a NEW run carries a new ceiling of its own —
// which is the whole reason the ceiling travels with the start.
paused: { label: 'status.paused', tone: 'warn', startable: true },
stopped: { label: 'status.stopped', startable: true },
rejected: { label: 'status.rejected', tone: 'danger' },
failed: { label: 'status.failed', tone: 'danger', startable: true },
},
// Neutral on purpose: a state from a future contract is not an error, and calling it one would
// put an alarm on a screen that the product forbids to look alarming.
//
// ⚠ NOT startable, and the direction is picked rather than defaulted: a minor bump adds states,
// and the states a pipeline gains are stages of a run in flight — exactly where starting a second
// one is refused. An action offered for a state this build cannot read would be a promise made on
// a guess; the state itself is still named on the card.
{ label: 'status.unknown' },
);
/**
* Why a run is paused. The API carries state, the phrase is drawn here — and the phrase for
* `credit_exhausted` is the owner's wording (D39.100 K-8), not ours.
*/
export const pausedReason = vocabulary<Schemas['PausedReason'], { label: MessageKey }>(
{ credit_exhausted: { label: 'paused.creditExhausted' } },
// An unknown reason claims nothing about the cause. It still says the stop is resumable,
// because that is true of every value of this vocabulary by contract.
{ label: 'paused.unknown' },
);
/**
* Why a book was rejected → what the reader is told and what they can do about it (0.2.3).
*
* Two things ride together because they are one decision: the phrase names what happened, the
* advice names the NEXT ACTION, and the next action is the whole reason the contract carries three
* values instead of a boolean. "The file is not one we can read" ends in checking the file; "not
* us, not now" ends in waiting. A vocabulary that carried only the phrase would leave the second
* half to be re-invented by every screen that shows a rejected book.
*
* ⚠ The wording is this session's own (owner's word 14.08) and awaits his approval. The boundary it
* is written inside is the one `Problem` states for the platform's phrases: no stages, no models,
* nothing about how the parsing works.
*/
export const rejectReason = vocabulary<
Schemas['RejectReason'],
{ label: MessageKey; advice: MessageKey }
>(
{
source_unreadable: {
label: 'rejected.sourceUnreadable',
advice: 'rejected.sourceUnreadableAdvice',
},
not_configured: { label: 'rejected.notConfigured', advice: 'rejected.notConfiguredAdvice' },
parser_unavailable: {
label: 'rejected.parserUnavailable',
advice: 'rejected.parserUnavailableAdvice',
},
},
// A reason this build does not know — and an absent one, which the contract says to render the
// same way — claims nothing about whose fault it is. It must not send the reader to check a file
// that may be perfectly good, nor promise that waiting will help.
{ label: 'rejected.unknown', advice: 'rejected.unknownAdvice' },
);
/**
* Severity step of a note → look of the callout.
*
* ⚠ An unknown step takes the ATTENTION branch, not the quiet one. The quiet default was the
* defect: a step from a future contract would be silently demoted to "have a glance", and the one
* thing a severity axis must never do is hide something that matters. The words for the steps
* themselves are the owner's and are not invented here (BACKLOG Ф-21, question В-3).
*/
export const noteSeverity = vocabulary<Schemas['NoteSeverity'], { tone: 'note' | 'quiet' }>(
{ attention: { tone: 'note' }, glance: { tone: 'quiet' } },
{ tone: 'note' },
);
/**
* Signing status of a bank row, three-valued. `canon` — whether the term is injected as canon; it
* is the one thing the contract states outright ("only `approved` is injected as canon").
*
* ⚠ No product WORDS here, and that is a decision rather than an omission. The three words this
* vocabulary carried ("proposed" · "draft" · "signed") came from the owner's remarks 7 and 13 (the
* bank has to be verifiable) and were rendered by no screen at all: remark 20 took the per-row
* signing state off the bank table — one signature covers the whole bank — and the signing screen
* with its verbs is S5. Unrendered product wording drifts silently from the wording that is finally
* chosen, so it is written when there is a screen to write it for.
*/
export const termStatus = vocabulary<Schemas['TermStatus'], { canon: boolean }>(
{ auto: { canon: false }, draft: { canon: false }, approved: { canon: true } },
// Safe direction: an unknown status does not get to claim the row is signed.
{ canon: false },
);
/**
* Kind of term. Not cosmetic: the contract says `name` and `place` ROUTE a term into
* transliteration, so a bank row shown without its kind is a row signed blind — which is why the
* reference view now carries it.
*
* The unknown branch is the contract's own requirement, not our invention: a row whose kind the
* engine could not decide "MUST be shown as kind not decided and MUST NOT be dropped".
*/
export const termKind = vocabulary<Schemas['TermKind'], { label: MessageKey }>(
{
name: { label: 'kind.name' },
place: { label: 'kind.place' },
title: { label: 'kind.title' },
term: { label: 'kind.term' },
nickname: { label: 'kind.nickname' },
},
{ label: 'kind.undecided' },
);
/** Provenance of a bank row. Narrowing only, same reason as `termKind`. */
export const termOrigin = vocabulary<Schemas['TermOrigin'], Record<string, never>>(
{ seed: {}, ruby: {}, mined: {} },
{},
);
/** State of a pair. Narrowing only: the reader already has one phrase for "no text yet". */
export const unitState = vocabulary<Schemas['UnitState'], Record<string, never>>(
{ translated: {}, withheld: {}, pending: {} },
{},
);
/** Credit balance state. No screen reads it before S7 (settings) — see BACKLOG Ф-27. */
export const usageState = vocabulary<Schemas['Usage']['state'], Record<string, never>>(
{ ok: {}, low: {}, exhausted: {} },
{},
);