diff --git a/lit_nlp/client/elements/lit_input_field.ts b/lit_nlp/client/elements/lit_input_field.ts
new file mode 100644
index 00000000..2e1cf367
--- /dev/null
+++ b/lit_nlp/client/elements/lit_input_field.ts
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @fileoverview A custom element that maps a LitType and (optionally) its
+ * corresponding value to an HTML representation for editing or display.
+ */
+
+// tslint:disable:no-new-decorators
+
+import {html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {AnnotationCluster, EdgeLabel, ScoredTextCandidate, ScoredTextCandidates, SpanLabel} from '../lib/dtypes';
+import {ReactiveElement} from '../lib/elements';
+import {BooleanLitType, EdgeLabels, Embeddings, GeneratedTextCandidates, LitType, MultiSegmentAnnotations, Scalar, SpanLabels, StringLitType} from '../lib/lit_types';
+import {formatAnnotationCluster, formatBoolean, formatEdgeLabel, formatEmbeddings, formatNumber, formatScoredTextCandidate, formatScoredTextCandidates, formatScoredTextCandidatesList, formatSpanLabel} from '../lib/type_rendering';
+import {chunkWords} from '../lib/utils';
+
+function isGeneratedTextCandidate(input: unknown): boolean {
+ return Array.isArray(input) && input.length === 2 &&
+ typeof input[0] === 'string' &&
+ (input[1] == null || typeof input[1] === 'number');
+}
+
+/**
+ * An element for mapping LitType fields to the correct HTML input type.
+ */
+@customElement('lit-input-field')
+export class LitInputField extends ReactiveElement {
+ @property({type: String}) name = '';
+ @property({type: Object}) type = new LitType();
+ @property({type: Boolean}) readonly = false;
+ @property() value: unknown;
+ @property({type: Boolean}) limitWords = false;
+
+ override render() {
+ return this.readonly ? this.renderReadonly() : this.renderEditable();
+ }
+
+ /**
+ * Converts the input value to a readonly representaiton for display pruposes.
+ */
+ private renderReadonly(): string|number {
+ if (Array.isArray(this.value)) {
+ if (isGeneratedTextCandidate(this.value)) {
+ return formatScoredTextCandidate(this.value as ScoredTextCandidate);
+ }
+
+ if (Array.isArray(this.value[0]) &&
+ isGeneratedTextCandidate(this.value[0])) {
+ return formatScoredTextCandidates(this.value as ScoredTextCandidates);
+ }
+
+ const strings = this.value.map((item) => {
+ if (typeof item === 'number') {return formatNumber(item);}
+ if (this.limitWords) {return chunkWords(item);}
+ return `${item}`;
+ });
+ return strings.join(', ');
+ } else if (this.type instanceof BooleanLitType) {
+ return formatBoolean(this.value as boolean);
+ } else if (this.type instanceof EdgeLabels) {
+ const formattedTags = (this.value as EdgeLabel[]).map(formatEdgeLabel);
+ return formattedTags.join(', ');
+ } else if (this.type instanceof Embeddings) {
+ return formatEmbeddings(this.value);
+ } else if (this.type instanceof GeneratedTextCandidates) {
+ return formatScoredTextCandidatesList(
+ this.value as ScoredTextCandidates[]);
+ } else if (this.type instanceof MultiSegmentAnnotations) {
+ const formattedTags =
+ (this.value as AnnotationCluster[]).map(formatAnnotationCluster);
+ return formattedTags.join(', ');
+ } else if (this.type instanceof Scalar || typeof this.value === 'number') {
+ return formatNumber(this.value as number);
+ } else if (this.type instanceof SpanLabels) {
+ const formattedTags =
+ (this.value as SpanLabel[]).map(s => formatSpanLabel(s)) as string[];
+ return formattedTags.join(', ');
+ } else if (this.type instanceof StringLitType ||
+ typeof this.value === 'string') {
+ return this.limitWords ?
+ chunkWords(this.value as string) : this.value as string;
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Renders the appropriate HTML elements to enable user input to control the
+ * value of the field with which this element is associated.
+ */
+ private renderEditable() {
+ if (this.type instanceof BooleanLitType) {
+ const change = (e: Event) => {
+ this.value = !!(e.target as HTMLInputElement).value;
+ dispatchEvent(new Event('change'));
+ };
+ return html``;
+ } else {
+ return html`Unsupported type '${this.type.name}' cannot be rnedered.`;
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'lit-input-field': LitInputField;
+ }
+}
diff --git a/lit_nlp/client/elements/lit_input_field_test.ts b/lit_nlp/client/elements/lit_input_field_test.ts
new file mode 100644
index 00000000..b11cf3a1
--- /dev/null
+++ b/lit_nlp/client/elements/lit_input_field_test.ts
@@ -0,0 +1,6 @@
+import 'jasmine';
+
+describe('lit input field test', () => {
+ it('computes a thing when asked', () => {
+ });
+});
diff --git a/lit_nlp/client/lib/type_rendering.ts b/lit_nlp/client/lib/type_rendering.ts
new file mode 100644
index 00000000..f5372539
--- /dev/null
+++ b/lit_nlp/client/lib/type_rendering.ts
@@ -0,0 +1,143 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * @fileoverview Functions for converting LitType classes to base HTML elements
+ * for editing or display.
+ *
+ * As a naming convention, exported functions that support editing via HTML
+ * inputs, selects, etc, start with "render" (because they used Lit.dev's HTML
+ * templates to render elements) and functions that are intended for readonly
+ * display start with "format" (because they typically convert the value to a
+ * string-like representation).
+ */
+
+import {html} from 'lit';
+import {StyleInfo, styleMap} from 'lit/directives/style-map';
+import {AnnotationCluster, EdgeLabel, ScoredTextCandidate, ScoredTextCandidates, SpanLabel} from './dtypes';
+
+type EventHandler = (e: Event) => void;
+
+/** Formats an AnnotationCluster for display. */
+export function formatAnnotationCluster(ac: AnnotationCluster): string {
+ return `${ac.label}${ac.score != null ? ` (${ac.score})` : ''}`;
+}
+
+/**
+ * Formats an AnnotationCluster[] (e.g., from MultiSegmentAnnotations) for
+ * display as a
of
s.
+ */
+export function formatAnnotationClusters(clusters: AnnotationCluster[]) {
+ return html`
${clusters.map(c =>
+ html`
+
${formatAnnotationCluster(c)}
+
${c.spans.map(s => html`
${formatSpanLabel(s, true)}
`)}
+
`)
+ }
`;
+}
+
+/** Formats a boolean value for display. */
+export function formatBoolean(val: boolean): string {
+ return val ? '✔' : ' ';
+}
+
+/** Formats an EdgeLabel for display. */
+export function formatEdgeLabel(e: EdgeLabel): string {
+ function formatSpan (s: [number, number]) {return `[${s[0]}, ${s[1]})`;}
+ const span1Text = formatSpan(e.span1);
+ const span2Text = e.span2 ? ` ← ${formatSpan(e.span2)}` : '';
+ // Add non-breaking control chars to keep this on one line
+ // TODO(lit-dev): get it to stop breaking between ) and :; \u2060 doesn't work
+ return `${span1Text}${span2Text}\u2060: ${e.label}`.replace(
+ /\ /g, '\u00a0' /* */);
+}
+
+/** Formats Embeddings for display. */
+export function formatEmbeddings(val: unknown): string {
+ return Array.isArray(val) ? `[${val.length}]` : '';
+}
+
+/** Formats a number to a fixed (3 decimal) value for display. */
+export function formatNumber (item: number): number {
+ return Number.isInteger(item) ? item : Number(item.toFixed(3));
+}
+
+/** Formats a ScoredTextCandidate for display. */
+export function formatScoredTextCandidate([t, s]: ScoredTextCandidate): string {
+ return `${t}${typeof s === 'number' ? ` (${formatNumber(s)})` : ''}`;
+}
+
+/** Formats a ScoredTextCandidates for display. */
+export function formatScoredTextCandidates(stc: ScoredTextCandidates): string {
+ return stc.map(formatScoredTextCandidate).join('\n\n');
+}
+
+/** Formats a list of ScoredTextCandidates for display. */
+export function formatScoredTextCandidatesList(
+ list: ScoredTextCandidates[]): string {
+ return list.map(formatScoredTextCandidates).join('\n\n');
+}
+
+/** Formats a SpanLabel for display.
+ *
+ * By default, it formats the SpanLabel as a string. If monospace is true, then
+ * the function returns an HTML Template wrapping the default string in a
+ * element.
+ */
+export function formatSpanLabel(s: SpanLabel, monospace = false) {
+ let formatted = `[${s.start}, ${s.end})`;
+ if (s.align) {
+ formatted = `${s.align} ${formatted}`;
+ }
+ if (s.label) {
+ // TODO(lit-dev): Stop from breaking between ) and :, \u2060 doesn't work
+ formatted = `${formatted}\u2060: ${s.label}`;
+ }
+
+ formatted = formatted.replace(/\ /g, '\u00a0' /* */);
+ return monospace ?
+ html`
${formatted}
` : formatted;
+}
+
+/** Formats a SpanLabel[] as a
of
s. */
+export function formatSpanLabels(labels: SpanLabel[]) {
+ return html`