200 lines
9.8 KiB
TypeScript
200 lines
9.8 KiB
TypeScript
// 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: {} },
|
||
{},
|
||
);
|