Release Format Lab 0.1.0

This commit is contained in:
2026-09-01 13:39:38 +02:00
commit cfc525a88e
59 changed files with 9840 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { lazy, Suspense, useState } from "react";
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import "./styles.css";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { HelpDialog } from "./components/HelpDialog";
import { manifest } from "./toolbox/manifest";
const Workbench = lazy(async () => ({
default: (await import("./components/Workbench")).Workbench,
}));
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<ErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
>
<Suspense
fallback={
<p className="loading" role="status">
Preparing Format Lab
</p>
}
>
<Workbench />
</Suspense>
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</ErrorBoundary>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
export class ErrorBoundary extends Component<
{ children: ReactNode },
{ error?: Error }
> {
state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Application failure", error, info);
}
render() {
if (this.state.error)
return (
<main className="fatal">
<h1>Format Lab could not continue</h1>
<p>{this.state.error.message}</p>
<button type="button" onClick={() => location.reload()}>
Reload
</button>
</main>
);
return this.props.children;
}
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useRef } from "react";
export function HelpDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
const node = dialog.current;
if (!node) return;
if (open && !node.open) node.showModal();
if (!open && node.open) node.close();
}, [open]);
return (
<dialog
ref={dialog}
className="help-dialog"
onClose={onClose}
onCancel={onClose}
aria-labelledby="help-title"
>
<div className="dialog-heading">
<div>
<p className="eyebrow">Local-first help</p>
<h2 id="help-title">About Format Lab</h2>
</div>
<button type="button" onClick={onClose} aria-label="Close help">
×
</button>
</div>
<p>
Compare declared conversion boundaries across data, image, audio/video,
and subtitle formats. Rank paths using only the properties relevant to
your task.
</p>
<p>
The structured-data lab executes bounded JSON, NDJSON, CSV, and typed
XML round trips locally. Expected comes from the catalog; measured
describes only the supplied sample and canonical model. Neither is an
unconditional lossless claim.
</p>
</dialog>
);
}
+563
View File
@@ -0,0 +1,563 @@
import { useMemo, useState, type ChangeEvent } from "react";
import {
stableStringify,
triggerBlobDownload,
} from "@add-ideas/toolbox-helpers";
import {
domains,
edges,
findRoutes,
formats,
labelFor,
properties,
type ConversionRoute,
type DomainId,
type Preservation,
} from "../lab/catalog";
import {
runRoundTrip,
type DataFormat,
type RoundTripResult,
} from "../lab/data";
const SAMPLE = `[
{
"id": 1,
"active": true,
"name": "Café 🚲",
"nullable": null,
"nested": { "score": 1.25 }
},
{
"id": 2,
"active": false,
"name": "=FORMULA",
"nullable": "",
"nested": [1, 2]
}
]`;
interface LabRecord {
result: RoundTripResult;
route: ConversionRoute;
}
function download(text: string, name: string, mediaType: string) {
triggerBlobDownload(new Blob([text], { type: mediaType }), name);
}
function statusLabel(status: Preservation): string {
if (status === "expected-preserved") return "Expected to preserve";
if (status === "expected-lost") return "Expected loss";
if (status === "conditional") return "Conditional";
return "Unknown";
}
function FormatGraph({
domain,
from,
to,
}: {
domain: DomainId;
from: string;
to: string;
}) {
const nodes = formats.filter((format) => format.domain === domain);
const positions = Object.fromEntries(
nodes.map((node, index) => {
const angle = (Math.PI * 2 * index) / nodes.length - Math.PI / 2;
return [
node.id,
{ x: 300 + Math.cos(angle) * 215, y: 190 + Math.sin(angle) * 135 },
];
}),
) as Record<string, { x: number; y: number }>;
const domainEdges = edges.filter(
(edge) => positions[edge.from] && positions[edge.to],
);
return (
<svg
className="format-graph"
viewBox="0 0 600 380"
role="img"
aria-label={`${domains.find((item) => item.id === domain)?.label} conversion graph from ${labelFor(from)} to ${labelFor(to)}`}
>
<defs>
<marker
id={`arrow-${domain}`}
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
</defs>
{domainEdges.map((edge, index) => {
const start = positions[edge.from]!;
const end = positions[edge.to]!;
return (
<line
key={`${edge.from}-${edge.to}-${index}`}
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
markerEnd={`url(#arrow-${domain})`}
/>
);
})}
{nodes.map((node) => {
const position = positions[node.id]!;
return (
<g
key={node.id}
className={
node.id === from
? "source-node"
: node.id === to
? "target-node"
: ""
}
>
<circle cx={position.x} cy={position.y} r="38" />
<text x={position.x} y={position.y + 5} textAnchor="middle">
{node.label}
</text>
</g>
);
})}
</svg>
);
}
const initialRoute = findRoutes(
"data",
"json",
"csv",
properties.data.map((item) => item.id),
)[0]!;
const initialRecord: LabRecord = {
route: initialRoute,
result: runRoundTrip(SAMPLE, initialRoute),
};
export function Workbench() {
const [domain, setDomain] = useState<DomainId>("data");
const [from, setFrom] = useState("json");
const [to, setTo] = useState("csv");
const [relevant, setRelevant] = useState(
properties.data.map((property) => property.id),
);
const [routeIndex, setRouteIndex] = useState(0);
const [source, setSource] = useState(SAMPLE);
const [sourceName, setSourceName] = useState("round-trip.json");
const [record, setRecord] = useState<LabRecord>(initialRecord);
const [outputView, setOutputView] = useState<"target" | "roundtrip">(
"target",
);
const [error, setError] = useState<string>();
const domainFormats = formats.filter((format) => format.domain === domain);
const routes = useMemo(
() => findRoutes(domain, from, to, relevant),
[domain, from, relevant, to],
);
const selectedRoute = routes[routeIndex] ?? routes[0];
const graphEdges = edges.filter(
(edge) =>
domainFormats.some((format) => format.id === edge.from) &&
domainFormats.some((format) => format.id === edge.to),
);
const report = useMemo(
() =>
`${stableStringify(
{
catalogVersion: "format-catalog-v1",
route: record.route.formats,
expected: record.route.summary,
measured: record.result.measurements,
notes: record.result.notes,
},
2,
{ maxTextChars: 2 * 1024 * 1024, maxDepth: 16, maxNodes: 20_000 },
)}\n`,
[record],
);
const selectDomain = (next: DomainId) => {
const nextFormats = formats.filter((format) => format.domain === next);
setDomain(next);
setFrom(nextFormats[0]!.id);
setTo(nextFormats[1]?.id ?? nextFormats[0]!.id);
setRelevant(properties[next].map((property) => property.id));
setRouteIndex(0);
};
const run = () => {
try {
if (domain !== "data" || !selectedRoute)
throw new TypeError(
"Measured conversion is available for structured-data routes in v0.1. Use the graph as an explicit planning contract for this domain.",
);
const result = runRoundTrip(source, selectedRoute);
setRecord({ route: selectedRoute, result });
setOutputView("target");
setError(undefined);
} catch (reason) {
setError(
`${reason instanceof Error ? reason.message : "Round trip failed."} The previous measured result was retained.`,
);
}
};
const openFile = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
setError("Input file exceeds 2 MiB. The previous result was retained.");
return;
}
const extension = file.name.split(".").at(-1)?.toLowerCase();
const format: DataFormat =
extension === "csv"
? "csv"
: extension === "xml"
? "xml"
: extension === "ndjson" || extension === "jsonl"
? "ndjson"
: "json";
setDomain("data");
setFrom(format);
setTo(format === "csv" ? "json" : "csv");
setRelevant(properties.data.map((property) => property.id));
setRouteIndex(0);
setSource(await file.text());
setSourceName(file.name);
setError(undefined);
};
const measuredByProperty = Object.fromEntries(
record.result.measurements.map((measurement) => [
measurement.property,
measurement,
]),
);
return (
<main className="workbench">
<section className="hero">
<div>
<p className="eyebrow">Conversion paths with evidence</p>
<h1>Format Lab</h1>
<p>
Rank routes by the properties you care about, then measure a real
bounded round trip where the lab has an executable adapter.
</p>
</div>
<span className="privacy-pill">Expected measured</span>
</section>
<section className="panel explorer" aria-labelledby="explorer-title">
<div className="panel-heading">
<div>
<h2 id="explorer-title">Format path explorer</h2>
<p>
No route is called lossless; every status names its evidence
boundary.
</p>
</div>
</div>
<div className="selection-grid">
<label>
Domain
<select
value={domain}
onChange={(event) => selectDomain(event.target.value as DomainId)}
>
{domains.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
<label>
From
<select
aria-label="From format"
value={from}
onChange={(event) => {
setFrom(event.target.value);
setRouteIndex(0);
}}
>
{domainFormats.map((format) => (
<option key={format.id} value={format.id}>
{format.label}
</option>
))}
</select>
</label>
<label>
To
<select
aria-label="To format"
value={to}
onChange={(event) => {
setTo(event.target.value);
setRouteIndex(0);
}}
>
{domainFormats.map((format) => (
<option key={format.id} value={format.id}>
{format.label}
</option>
))}
</select>
</label>
</div>
<div className="explorer-grid">
<FormatGraph domain={domain} from={from} to={to} />
<div>
<h3>Relevant properties</h3>
<div className="property-list">
{properties[domain].map((property) => (
<label key={property.id}>
<input
type="checkbox"
checked={relevant.includes(property.id)}
onChange={(event) => {
setRelevant((current) =>
event.target.checked
? [...current, property.id]
: current.filter((item) => item !== property.id),
);
setRouteIndex(0);
}}
/>
{property.label}
</label>
))}
</div>
</div>
</div>
<h3>Recommended paths</h3>
<div className="route-list">
{routes.slice(0, 5).map((route, index) => (
<button
type="button"
className="route-card"
aria-pressed={selectedRoute === route}
key={route.formats.join(">")}
onClick={() => setRouteIndex(index)}
>
<strong>{route.formats.map(labelFor).join(" → ")}</strong>
<span>
{route.edges.length || 0} conversion step
{route.edges.length === 1 ? "" : "s"}
</span>
<span className="route-statuses">
{Object.entries(route.summary).map(([property, status]) => (
<small className={status} key={property}>
{
properties[domain].find((item) => item.id === property)
?.label
}
: {statusLabel(status)}
</small>
))}
</span>
</button>
))}
{!routes.length ? (
<p className="empty">
No bounded catalog path connects these formats.
</p>
) : null}
</div>
<details>
<summary>
Inspect all {graphEdges.length} directed conversion contracts
</summary>
<div className="table-scroll" tabIndex={0}>
<table>
<thead>
<tr>
<th>From</th>
<th>To</th>
<th>Available in</th>
<th>Boundary</th>
</tr>
</thead>
<tbody>
{graphEdges.map((edge, index) => (
<tr key={`${edge.from}-${edge.to}-${index}`}>
<td>{labelFor(edge.from)}</td>
<td>{labelFor(edge.to)}</td>
<td>{edge.availability}</td>
<td>{edge.note}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</section>
<section className="panel" aria-labelledby="lab-title">
<div className="panel-heading">
<div>
<h2 id="lab-title">Measured structured-data round trip</h2>
<p>
{sourceName} · JSON, NDJSON, CSV and the typed lab XML record
dialect
</p>
</div>
<label className="button file-button">
Open data
<input
type="file"
data-testid="data-file"
accept=".json,.jsonl,.ndjson,.csv,.xml,text/csv,application/json,text/xml"
onChange={(event) => void openFile(event)}
/>
</label>
</div>
<div className="lab-grid">
<div>
<label>
Source data
<textarea
aria-label="Source data"
value={source}
onChange={(event) => setSource(event.target.value)}
rows={22}
spellCheck={false}
/>
</label>
<button
className="primary-button top-gap"
type="button"
disabled={domain !== "data" || !selectedRoute}
onClick={run}
>
Run selected round trip
</button>
{domain !== "data" ? (
<p className="warning">
This v0.1 lab exposes catalog contracts for{" "}
{domains.find((item) => item.id === domain)?.label}; executable
conversion remains in the specialized Toolbox app.
</p>
) : null}
{error ? (
<p className="error" role="alert">
{error}
</p>
) : null}
</div>
<div>
<nav className="tabs" aria-label="Output views">
<button
type="button"
aria-pressed={outputView === "target"}
onClick={() => setOutputView("target")}
>
Target output
</button>
<button
type="button"
aria-pressed={outputView === "roundtrip"}
onClick={() => setOutputView("roundtrip")}
>
Round-trip source
</button>
</nav>
<textarea
aria-label="Conversion output"
readOnly
value={
outputView === "target"
? record.result.targetText
: record.result.roundTripText
}
rows={22}
spellCheck={false}
/>
<div className="action-row top-gap">
<button
type="button"
onClick={() =>
download(
record.result.targetText,
`converted.${record.route.formats.at(-1)}`,
"text/plain;charset=utf-8",
)
}
>
Download target
</button>
<button
type="button"
onClick={() =>
download(report, "format-lab-report.json", "application/json")
}
>
Download evidence report
</button>
</div>
</div>
</div>
<div className="table-scroll evidence-table" tabIndex={0}>
<table>
<thead>
<tr>
<th>Property</th>
<th>Catalog expectation</th>
<th>Round-trip measurement</th>
<th>Evidence boundary</th>
</tr>
</thead>
<tbody>
{properties.data.map((property) => {
const measurement = measuredByProperty[property.id];
return (
<tr key={property.id}>
<th>{property.label}</th>
<td>
<span
className={`status ${record.route.summary[property.id]}`}
>
{statusLabel(
record.route.summary[property.id] ?? "unknown",
)}
</span>
</td>
<td>
<span className={`status ${measurement?.status}`}>
{measurement?.status.replaceAll("-", " ")}
</span>
</td>
<td>{measurement?.detail}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{record.result.notes.length ? (
<ul className="notice-list">
{record.result.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
) : null}
</section>
</main>
);
}
+607
View File
@@ -0,0 +1,607 @@
export type DomainId = "data" | "image" | "av" | "subtitle";
export type Preservation =
"expected-preserved" | "conditional" | "expected-lost" | "unknown";
export interface FormatDefinition {
id: string;
label: string;
domain: DomainId;
note: string;
}
export interface PropertyDefinition {
id: string;
label: string;
}
export interface ConversionEdge {
from: string;
to: string;
availability: "lab" | "toolbox" | "browser" | "catalog";
statuses: Record<string, Preservation>;
note: string;
}
export interface ConversionRoute {
formats: string[];
edges: ConversionEdge[];
score: number;
summary: Record<string, Preservation>;
}
export const domains: Array<{ id: DomainId; label: string }> = [
{ id: "data", label: "Structured data" },
{ id: "image", label: "Images" },
{ id: "av", label: "Audio & video" },
{ id: "subtitle", label: "Subtitles" },
];
export const properties: Record<DomainId, PropertyDefinition[]> = {
data: [
{ id: "record-order", label: "Record order" },
{ id: "field-names", label: "Field names" },
{ id: "field-order", label: "Field order" },
{ id: "scalar-types", label: "Scalar types" },
{ id: "nulls", label: "Null values" },
{ id: "nested", label: "Nested values" },
{ id: "numbers", label: "Numeric values" },
{ id: "unicode", label: "Unicode text" },
{ id: "metadata", label: "Document metadata" },
],
image: [
{ id: "dimensions", label: "Dimensions" },
{ id: "pixels", label: "Pixel values" },
{ id: "alpha", label: "Alpha channel" },
{ id: "colour", label: "Colour profile / depth" },
{ id: "metadata", label: "EXIF / metadata" },
{ id: "animation", label: "Animation" },
{ id: "vectors", label: "Vector geometry" },
{ id: "editable-text", label: "Editable text" },
],
av: [
{ id: "duration", label: "Duration" },
{ id: "timestamps", label: "Timestamps" },
{ id: "video", label: "Video samples" },
{ id: "audio", label: "Audio samples" },
{ id: "channels", label: "Channel layout" },
{ id: "subtitles", label: "Subtitle tracks" },
{ id: "chapters", label: "Chapters" },
{ id: "metadata", label: "Container metadata" },
],
subtitle: [
{ id: "timing", label: "Cue timing" },
{ id: "text", label: "Cue text" },
{ id: "ids", label: "Cue identifiers" },
{ id: "position", label: "Position / regions" },
{ id: "styles", label: "Styling" },
{ id: "speakers", label: "Speaker semantics" },
{ id: "metadata", label: "Document metadata" },
],
};
export const formats: FormatDefinition[] = [
{
id: "json",
label: "JSON",
domain: "data",
note: "Nested typed values; object order is conventional, not semantic.",
},
{
id: "ndjson",
label: "NDJSON",
domain: "data",
note: "One JSON record per line; no document wrapper.",
},
{
id: "csv",
label: "CSV",
domain: "data",
note: "Rectangular text cells; dialect and type schema are external.",
},
{
id: "xml",
label: "XML",
domain: "data",
note: "Flexible tree with namespaces, attributes and mixed content.",
},
{
id: "png",
label: "PNG",
domain: "image",
note: "Raster, lossless compression, alpha and optional metadata.",
},
{
id: "jpeg",
label: "JPEG",
domain: "image",
note: "Lossy raster without alpha; broad photo support.",
},
{
id: "webp",
label: "WebP",
domain: "image",
note: "Lossy or lossless raster, alpha and animation.",
},
{
id: "svg",
label: "SVG",
domain: "image",
note: "Vector/XML document with text and external-resource risks.",
},
{
id: "wav",
label: "WAV",
domain: "av",
note: "Audio container, commonly uncompressed PCM.",
},
{
id: "flac",
label: "FLAC",
domain: "av",
note: "Lossless compressed audio and metadata.",
},
{
id: "mp3",
label: "MP3",
domain: "av",
note: "Lossy audio with limited channel/metadata models.",
},
{
id: "opus",
label: "Opus",
domain: "av",
note: "Lossy speech/music codec, commonly in Ogg or WebM.",
},
{
id: "mp4",
label: "MP4",
domain: "av",
note: "Timed media container; actual preservation depends on streams.",
},
{
id: "webm",
label: "WebM",
domain: "av",
note: "Web-oriented Matroska profile with constrained codecs.",
},
{
id: "mkv",
label: "Matroska",
domain: "av",
note: "Flexible multi-track container with attachments and chapters.",
},
{
id: "srt",
label: "SRT",
domain: "subtitle",
note: "Simple numbered cues with de-facto inline formatting.",
},
{
id: "vtt",
label: "WebVTT",
domain: "subtitle",
note: "Cue identifiers, settings, regions and web timing.",
},
{
id: "ass",
label: "ASS",
domain: "subtitle",
note: "Rich scripted styling, positioning and events.",
},
{
id: "ttml",
label: "TTML",
domain: "subtitle",
note: "XML timing and styling profiles vary by ecosystem.",
},
];
function statuses(
domain: DomainId,
values: Partial<Record<string, Preservation>>,
): Record<string, Preservation> {
return Object.fromEntries(
properties[domain].map(({ id }) => [id, values[id] ?? "unknown"]),
);
}
const dataTyped = statuses("data", {
"record-order": "expected-preserved",
"field-names": "expected-preserved",
"field-order": "conditional",
"scalar-types": "expected-preserved",
nulls: "expected-preserved",
nested: "expected-preserved",
numbers: "conditional",
unicode: "expected-preserved",
metadata: "conditional",
});
const dataCsv = statuses("data", {
"record-order": "expected-preserved",
"field-names": "expected-preserved",
"field-order": "conditional",
"scalar-types": "expected-lost",
nulls: "expected-lost",
nested: "conditional",
numbers: "conditional",
unicode: "conditional",
metadata: "expected-lost",
});
const rasterLossy = statuses("image", {
dimensions: "expected-preserved",
pixels: "expected-lost",
alpha: "expected-lost",
colour: "conditional",
metadata: "expected-lost",
animation: "expected-lost",
vectors: "expected-lost",
"editable-text": "expected-lost",
});
const rasterConditional = statuses("image", {
dimensions: "expected-preserved",
pixels: "conditional",
alpha: "conditional",
colour: "conditional",
metadata: "expected-lost",
animation: "conditional",
vectors: "expected-lost",
"editable-text": "expected-lost",
});
const audioLossy = statuses("av", {
duration: "conditional",
timestamps: "conditional",
video: "unknown",
audio: "expected-lost",
channels: "conditional",
subtitles: "expected-lost",
chapters: "expected-lost",
metadata: "conditional",
});
const audioLossless = statuses("av", {
duration: "expected-preserved",
timestamps: "conditional",
video: "unknown",
audio: "expected-preserved",
channels: "expected-preserved",
subtitles: "expected-lost",
chapters: "expected-lost",
metadata: "conditional",
});
const remux = statuses("av", {
duration: "conditional",
timestamps: "conditional",
video: "conditional",
audio: "conditional",
channels: "conditional",
subtitles: "conditional",
chapters: "conditional",
metadata: "conditional",
});
const subtitleSimple = statuses("subtitle", {
timing: "conditional",
text: "expected-preserved",
ids: "conditional",
position: "expected-lost",
styles: "expected-lost",
speakers: "conditional",
metadata: "expected-lost",
});
const subtitleRich = statuses("subtitle", {
timing: "conditional",
text: "expected-preserved",
ids: "conditional",
position: "conditional",
styles: "conditional",
speakers: "conditional",
metadata: "conditional",
});
function edge(
from: string,
to: string,
availability: ConversionEdge["availability"],
values: Record<string, Preservation>,
note: string,
): ConversionEdge {
return { from, to, availability, statuses: { ...values }, note };
}
export const edges: ConversionEdge[] = [
edge(
"json",
"ndjson",
"lab",
dataTyped,
"Wrapper-level values and metadata need an explicit record mapping.",
),
edge(
"ndjson",
"json",
"lab",
dataTyped,
"The lab writes an array; an original wrapper is not reconstructed.",
),
edge(
"json",
"xml",
"lab",
dataTyped,
"Measured lab XML uses typed field elements; arbitrary XML mappings differ.",
),
edge(
"xml",
"json",
"lab",
dataTyped,
"Generic XML attributes/mixed content are outside the lab record dialect.",
),
edge(
"ndjson",
"xml",
"lab",
dataTyped,
"Records pass through the bounded canonical model.",
),
edge(
"xml",
"ndjson",
"lab",
dataTyped,
"Document-level XML information is not part of NDJSON.",
),
...["json", "ndjson", "xml"].flatMap((format) => [
edge(
format,
"csv",
"lab",
dataCsv,
"CSV has text cells and no intrinsic null/type/nesting schema.",
),
edge(
"csv",
format,
"lab",
dataCsv,
"CSV cells enter the canonical model as strings.",
),
]),
edge(
"png",
"jpeg",
"browser",
rasterLossy,
"Canvas/JPEG encoding is lossy and flattens alpha.",
),
edge(
"webp",
"jpeg",
"browser",
rasterLossy,
"Decode/re-encode can lose alpha, animation and metadata.",
),
edge(
"svg",
"png",
"toolbox",
rasterConditional,
"Rasterization fixes a resolution and loses editability.",
),
edge(
"svg",
"webp",
"toolbox",
rasterConditional,
"Rasterization and WebP mode/settings determine pixel loss.",
),
edge(
"jpeg",
"png",
"browser",
rasterConditional,
"PNG cannot restore JPEG-discarded samples or metadata.",
),
edge(
"png",
"webp",
"browser",
rasterConditional,
"WebP lossless/lossy mode and animation handling must be measured.",
),
edge(
"webp",
"png",
"browser",
rasterConditional,
"A still PNG cannot retain WebP animation; metadata is commonly stripped.",
),
edge(
"wav",
"flac",
"toolbox",
audioLossless,
"PCM compatibility and metadata mapping still need inspection.",
),
edge(
"flac",
"wav",
"toolbox",
audioLossless,
"Decoded samples can match while metadata/container timing changes.",
),
...["wav", "flac"].flatMap((from) =>
["mp3", "opus"].map((to) =>
edge(
from,
to,
"toolbox",
audioLossy,
"A lossy audio encode discards sample information.",
),
),
),
...["mp3", "opus"].flatMap((from) =>
["wav", "flac"].map((to) =>
edge(
from,
to,
"toolbox",
audioLossy,
"Decoding does not restore information discarded by the source codec.",
),
),
),
edge(
"mp4",
"mkv",
"toolbox",
remux,
"Stream copy is conditional on codec and attachment/metadata mapping.",
),
edge(
"mkv",
"mp4",
"toolbox",
remux,
"MP4 may not represent every Matroska track, attachment or chapter.",
),
edge(
"mkv",
"webm",
"toolbox",
remux,
"WebM accepts a constrained codec/track subset.",
),
edge(
"webm",
"mkv",
"toolbox",
remux,
"The streams may copy while container metadata changes.",
),
edge(
"mp4",
"webm",
"toolbox",
statuses("av", {
...remux,
video: "expected-lost",
audio: "expected-lost",
}),
"Typical codecs require transcoding; inspect each stream.",
),
edge(
"webm",
"mp4",
"toolbox",
statuses("av", {
...remux,
video: "expected-lost",
audio: "expected-lost",
}),
"Typical codecs require transcoding; inspect each stream.",
),
...["srt", "vtt", "ass", "ttml"].flatMap((from) =>
["srt", "vtt", "ass", "ttml"]
.filter((to) => to !== from)
.map((to) =>
edge(
from,
to,
"toolbox",
from === "srt" || to === "srt" ? subtitleSimple : subtitleRich,
"Timing precision, profiles and styling constructs require cue-level verification.",
),
),
),
];
const rank: Record<Preservation, number> = {
"expected-preserved": 0,
conditional: 2,
unknown: 4,
"expected-lost": 8,
};
function summarize(
route: ConversionEdge[],
relevant: string[],
): Record<string, Preservation> {
return Object.fromEntries(
relevant.map((property) => {
const worst = route.reduce<Preservation>(
(current, item) =>
rank[item.statuses[property] ?? "unknown"] > rank[current]
? (item.statuses[property] ?? "unknown")
: current,
"expected-preserved",
);
return [property, worst];
}),
);
}
export function findRoutes(
domain: DomainId,
from: string,
to: string,
relevant: string[],
maxHops = 4,
): ConversionRoute[] {
const allowed = new Set(
formats
.filter((format) => format.domain === domain)
.map((format) => format.id),
);
if (!allowed.has(from) || !allowed.has(to)) return [];
if (from === to)
return [
{
formats: [from],
edges: [],
score: 0,
summary: Object.fromEntries(
relevant.map((property) => [property, "expected-preserved"]),
),
},
];
const routes: ConversionRoute[] = [];
const visit = (
current: string,
path: string[],
pathEdges: ConversionEdge[],
) => {
if (pathEdges.length >= maxHops) return;
for (const item of edges.filter(
(candidate) => candidate.from === current && allowed.has(candidate.to),
)) {
if (path.includes(item.to)) continue;
const nextPath = [...path, item.to];
const nextEdges = [...pathEdges, item];
if (item.to === to) {
const summary = summarize(nextEdges, relevant);
routes.push({
formats: nextPath,
edges: nextEdges,
summary,
score:
Object.values(summary).reduce(
(sum, status) => sum + rank[status],
0,
) +
nextEdges.length * 0.1,
});
} else visit(item.to, nextPath, nextEdges);
}
};
visit(from, [from], []);
return routes.sort(
(left, right) =>
left.score - right.score ||
left.edges.length - right.edges.length ||
left.formats.join(">").localeCompare(right.formats.join(">")),
);
}
export function labelFor(format: string): string {
return formats.find((item) => item.id === format)?.label ?? format;
}
+457
View File
@@ -0,0 +1,457 @@
import {
parseCsv,
safeJsonParse,
stableStringify,
stringifyCsv,
} from "@add-ideas/toolbox-helpers";
import type { ConversionRoute } from "./catalog";
export type DataFormat = "json" | "ndjson" | "csv" | "xml";
export type MeasurementStatus =
"measured-preserved" | "measured-changed" | "not-tested";
export interface DataDocument {
rows: Array<Record<string, unknown>>;
notes: string[];
}
export interface PropertyMeasurement {
property: string;
status: MeasurementStatus;
detail: string;
}
export interface RoundTripResult {
route: string[];
targetText: string;
roundTripText: string;
original: DataDocument;
roundTrip: DataDocument;
measurements: PropertyMeasurement[];
notes: string[];
}
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
const MAX_ROWS = 10_000;
const MAX_COLUMNS = 200;
const MAX_VALUES = 500_000;
const dangerous = new Set(["__proto__", "constructor", "prototype"]);
const encoder = new TextEncoder();
function byteLength(value: string): number {
return encoder.encode(value).byteLength;
}
function assertText(value: string, label: string): string {
if (byteLength(value) > MAX_SOURCE_BYTES)
throw new RangeError(`${label} exceeds the 2 MiB input limit.`);
if (value.includes("\0"))
throw new SyntaxError(`${label} contains NUL bytes.`);
return value;
}
function object(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function validateRows(rows: unknown[], notes: string[]): DataDocument {
if (rows.length > MAX_ROWS)
throw new RangeError(
`Data is limited to ${MAX_ROWS.toLocaleString()} records.`,
);
let values = 0;
const normalized = rows.map((value, index) => {
const record = object(value);
if (!record) throw new TypeError(`Record ${index + 1} must be an object.`);
const keys = Object.keys(record);
if (keys.length > MAX_COLUMNS)
throw new RangeError(
`Record ${index + 1} exceeds ${MAX_COLUMNS} fields.`,
);
for (const key of keys)
if (dangerous.has(key))
throw new TypeError(`Dangerous field name ${key} is rejected.`);
values += keys.length;
if (values > MAX_VALUES)
throw new RangeError(
`Data is limited to ${MAX_VALUES.toLocaleString()} top-level values.`,
);
return Object.fromEntries(keys.map((key) => [key, record[key]]));
});
return { rows: normalized, notes };
}
function parseJson(source: string): DataDocument {
const value = safeJsonParse(assertText(source, "JSON"), {
maxTextChars: MAX_SOURCE_BYTES,
maxDepth: 64,
maxNodes: 200_000,
rejectDangerousKeys: true,
});
const numericNote =
"Numeric measurement begins after browser JSON parsing; lexical precision beyond IEEE-754 is not independently tested.";
if (Array.isArray(value)) return validateRows(value, [numericNote]);
const root = object(value);
if (root && Array.isArray(root.records))
return validateRows(root.records, [
"The JSON records wrapper was mapped to rows; sibling wrapper metadata is outside the canonical model.",
numericNote,
]);
if (root)
return validateRows(
[root],
["A top-level JSON object was treated as one record.", numericNote],
);
throw new TypeError(
"JSON must be an array of objects, an object, or an object with a records array.",
);
}
function parseNdjson(source: string): DataDocument {
const text = assertText(source, "NDJSON");
const lines = text.split(/\r\n|\n|\r/gu).filter((line) => line.trim());
if (lines.length > MAX_ROWS)
throw new RangeError(
`NDJSON is limited to ${MAX_ROWS.toLocaleString()} records.`,
);
const rows = lines.map((line, index) => {
try {
return safeJsonParse(line, {
maxTextChars: MAX_SOURCE_BYTES,
maxDepth: 64,
maxNodes: 100_000,
rejectDangerousKeys: true,
});
} catch (reason) {
throw new SyntaxError(
`NDJSON line ${index + 1}: ${reason instanceof Error ? reason.message : "invalid JSON"}`,
{ cause: reason },
);
}
});
return validateRows(rows, [
"Numeric measurement begins after browser JSON parsing; lexical precision beyond IEEE-754 is not independently tested.",
]);
}
function parseCsvDocument(source: string): DataDocument {
const table = parseCsv(assertText(source, "CSV"), {
maxRows: MAX_ROWS + 1,
maxColumns: MAX_COLUMNS,
maxFieldChars: 256 * 1024,
});
const headers = table[0] ?? [];
if (!headers.length || headers.some((header) => !header))
throw new TypeError("CSV needs a non-empty heading row.");
if (new Set(headers).size !== headers.length)
throw new TypeError("CSV heading names must be unique.");
headers.forEach((header) => {
if (dangerous.has(header))
throw new TypeError(`Dangerous field name ${header} is rejected.`);
});
const rows = table
.slice(1)
.map((cells) =>
Object.fromEntries(
headers.map((header, index) => [header, cells[index] ?? ""]),
),
);
return validateRows(rows, [
"CSV cells were read as strings because CSV carries no intrinsic scalar-type or null schema.",
]);
}
function decodeField(element: Element, notes: string[]): unknown {
const type = element.getAttribute("type") ?? "string";
const text = element.textContent ?? "";
if (type === "string") return text;
if (type === "null") return null;
if (type === "boolean") {
if (text === "true") return true;
if (text === "false") return false;
}
if (type === "number") {
const number = Number(text);
if (Number.isFinite(number)) return number;
}
if (type === "json")
try {
return safeJsonParse(text, {
maxTextChars: MAX_SOURCE_BYTES,
maxDepth: 64,
maxNodes: 100_000,
rejectDangerousKeys: true,
});
} catch {
/* fall through to a visible string and note */
}
notes.push(
`XML field ${element.getAttribute("name") ?? element.localName} has an invalid/unknown ${type} type marker and was kept as text.`,
);
return text;
}
function parseXmlDocument(source: string): DataDocument {
const text = assertText(source, "XML");
if (/<!DOCTYPE/iu.test(text))
throw new SyntaxError("DOCTYPE is disabled for XML conversion.");
const document = new DOMParser().parseFromString(text, "application/xml");
if (document.querySelector("parsererror"))
throw new SyntaxError("XML is not well formed.");
const rowElements = Array.from(document.documentElement.children).filter(
(element) => element.localName === "row",
);
if (!rowElements.length)
throw new TypeError(
"XML needs row elements directly below its document element.",
);
const notes: string[] = [];
const typed = rowElements.some((row) =>
Array.from(row.children).some(
(field) => field.localName === "field" && field.hasAttribute("name"),
),
);
if (!typed)
notes.push(
"Generic XML child elements were mapped to string fields; attributes, namespaces, comments and mixed content are outside this record mapping.",
);
const rows = rowElements.map((row, rowIndex) => {
const record: Record<string, unknown> = Object.create(null) as Record<
string,
unknown
>;
for (const field of Array.from(row.children)) {
const name =
typed && field.localName === "field"
? field.getAttribute("name")
: field.localName;
if (!name)
throw new TypeError(`XML row ${rowIndex + 1} has an unnamed field.`);
if (dangerous.has(name))
throw new TypeError(`Dangerous field name ${name} is rejected.`);
if (Object.hasOwn(record, name))
throw new TypeError(
`XML row ${rowIndex + 1} repeats field ${name}; arrays need an explicit typed JSON field.`,
);
record[name] = typed
? decodeField(field, notes)
: (field.textContent ?? "");
}
return record;
});
return validateRows(rows, notes);
}
export function parseData(source: string, format: DataFormat): DataDocument {
if (format === "json") return parseJson(source);
if (format === "ndjson") return parseNdjson(source);
if (format === "csv") return parseCsvDocument(source);
return parseXmlDocument(source);
}
function escapeXml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function xmlField(name: string, value: unknown): string {
const [type, text] =
value === null
? ["null", ""]
: typeof value === "string"
? ["string", value]
: typeof value === "number"
? ["number", String(value)]
: typeof value === "boolean"
? ["boolean", String(value)]
: [
"json",
stableStringify(value, undefined, {
maxTextChars: MAX_OUTPUT_BYTES,
maxDepth: 64,
maxNodes: 200_000,
}),
];
return `<field name="${escapeXml(name)}" type="${type}">${escapeXml(text)}</field>`;
}
function csvCell(value: unknown): unknown {
const text =
value === null
? ""
: typeof value === "object"
? stableStringify(value, undefined, {
maxTextChars: MAX_OUTPUT_BYTES,
maxDepth: 64,
maxNodes: 200_000,
})
: String(value);
return /^[\t\r\n ]*[=+\-@]/u.test(text) ? `'${text}` : text;
}
export function serializeData(
document: DataDocument,
format: DataFormat,
): string {
let output: string;
if (format === "json") output = `${JSON.stringify(document.rows, null, 2)}\n`;
else if (format === "ndjson")
output = `${document.rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
else if (format === "csv") {
const headers: string[] = [];
const seen = new Set<string>();
document.rows.forEach((row) =>
Object.keys(row).forEach((key) => {
if (!seen.has(key)) {
seen.add(key);
headers.push(key);
}
}),
);
output = `${stringifyCsv([
headers,
...document.rows.map((row) =>
headers.map((header) => csvCell(row[header])),
),
])}\r\n`;
} else
output = `<?xml version="1.0" encoding="UTF-8"?>\n<records>\n${document.rows
.map(
(row) =>
` <row>${Object.entries(row)
.map(([name, value]) => xmlField(name, value))
.join("")}</row>`,
)
.join("\n")}\n</records>\n`;
if (byteLength(output) > MAX_OUTPUT_BYTES)
throw new RangeError("Converted output exceeds the 8 MiB limit.");
return output;
}
function scalarType(value: unknown): string {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
return typeof value === "object" ? "object" : typeof value;
}
function signature(
rows: Array<Record<string, unknown>>,
property: string,
): string {
if (property === "record-order")
return stableStringify(rows.map((row) => stableStringify(row)));
if (property === "field-names")
return stableStringify(rows.map((row) => Object.keys(row).sort()));
if (property === "field-order")
return stableStringify(rows.map((row) => Object.keys(row)));
const values: Array<[string, unknown]> = [];
const walk = (value: unknown, path: string) => {
if (property === "scalar-types") values.push([path, scalarType(value)]);
if (property === "nulls" && value === null) values.push([path, null]);
if (property === "nested" && value !== null && typeof value === "object")
values.push([path, value]);
if (property === "numbers" && typeof value === "number")
values.push([path, value]);
if (property === "unicode" && typeof value === "string")
values.push([path, value]);
if (Array.isArray(value))
value.forEach((item, index) => walk(item, `${path}[${index}]`));
else {
const record = object(value);
if (record)
Object.entries(record).forEach(([key, item]) =>
walk(item, `${path}.${key}`),
);
}
};
rows.forEach((row, rowIndex) =>
Object.entries(row).forEach(([key, value]) =>
walk(value, `${rowIndex}.${key}`),
),
);
return stableStringify(values);
}
export function measureData(
original: DataDocument,
roundTrip: DataDocument,
): PropertyMeasurement[] {
return [
"record-order",
"field-names",
"field-order",
"scalar-types",
"nulls",
"nested",
"numbers",
"unicode",
]
.map<PropertyMeasurement>((property) => {
const left = signature(original.rows, property);
const right = signature(roundTrip.rows, property);
return {
property,
status: left === right ? "measured-preserved" : "measured-changed",
detail:
left === right
? "The bounded canonical signatures matched after the complete round trip."
: "The bounded canonical signatures differed after the complete round trip.",
};
})
.concat({
property: "metadata",
status: "not-tested",
detail:
"The record lab does not model arbitrary document metadata, comments, namespaces or wrappers.",
});
}
export function runRoundTrip(
source: string,
route: ConversionRoute,
): RoundTripResult {
if (
!route.formats.length ||
route.formats.some(
(format) => !["json", "ndjson", "csv", "xml"].includes(format),
)
)
throw new TypeError(
"The measured lab currently runs structured-data routes only.",
);
const sourceFormat = route.formats[0] as DataFormat;
const original = parseData(source, sourceFormat);
let current = original;
let targetText = source;
const notes = [...original.notes];
for (const format of route.formats.slice(1)) {
targetText = serializeData(current, format as DataFormat);
current = parseData(targetText, format as DataFormat);
notes.push(
...current.notes.map((note) => `${format.toUpperCase()}: ${note}`),
);
}
const roundTripText = serializeData(current, sourceFormat);
const roundTrip = parseData(roundTripText, sourceFormat);
notes.push(
...roundTrip.notes.map(
(note) => `Round-trip ${sourceFormat.toUpperCase()}: ${note}`,
),
);
return {
route: route.formats,
targetText,
roundTripText,
original,
roundTrip,
measurements: measureData(original, roundTrip),
notes: [...new Set(notes)],
};
}
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
const url = new URL("./sw.js", document.baseURI);
void navigator.serviceWorker
.register(url, { scope: new URL("./", document.baseURI).pathname })
.catch(() => undefined);
});
}
+768
View File
@@ -0,0 +1,768 @@
:root {
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: #242233;
background: #f3f4f8;
font-synthesis: none;
--surface: #fff;
--surface-muted: #f3f1fa;
--line: #d8d6e5;
--ink-muted: #66637a;
--accent: #5b4ec4;
--accent-dark: #4336a4;
--danger: #a5263d;
--warning: #8a5700;
--success: #187148;
}
:root[data-toolbox-theme="dark"] {
color: #f0eff8;
background: #15141b;
--surface: #211f29;
--surface-muted: #2a2735;
--line: #454153;
--ink-muted: #b9b5c8;
--accent: #a99cff;
--accent-dark: #c0b7ff;
--danger: #ff91a2;
--warning: #ffd078;
--success: #78d7a9;
}
@media (prefers-color-scheme: dark) {
:root:not([data-toolbox-theme="light"]) {
color: #f0eff8;
background: #15141b;
--surface: #211f29;
--surface-muted: #2a2735;
--line: #454153;
--ink-muted: #b9b5c8;
--accent: #a99cff;
--accent-dark: #c0b7ff;
--danger: #ff91a2;
--warning: #ffd078;
--success: #78d7a9;
}
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background: inherit;
color: inherit;
}
button,
input,
select,
textarea {
color: inherit;
font: inherit;
}
button,
.button,
input,
select,
textarea {
border: 1px solid var(--line);
border-radius: 0.72rem;
background: var(--surface);
}
button,
.button {
min-height: 2.65rem;
padding: 0.58rem 0.9rem;
cursor: pointer;
font-weight: 750;
}
button:hover,
.button:hover {
border-color: var(--accent);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
.button:focus-within {
outline: 3px solid color-mix(in srgb, var(--accent) 36%, transparent);
outline-offset: 2px;
}
input,
select,
textarea {
width: 100%;
min-height: 2.55rem;
padding: 0.55rem 0.65rem;
}
textarea {
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.84rem;
line-height: 1.5;
tab-size: 2;
}
.workbench {
width: min(100%, 90rem);
margin: 0 auto;
padding: 1.4rem clamp(0.75rem, 2.5vw, 2rem) 3rem;
}
.hero,
.panel-heading,
.source-grid,
.option-grid,
.tabs,
.workspace-grid,
.range-inputs {
display: flex;
}
.hero {
align-items: flex-start;
justify-content: space-between;
gap: 1.2rem;
margin-bottom: 1rem;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0.35rem;
font-size: clamp(1.85rem, 4vw, 3rem);
}
h2 {
margin-bottom: 0;
font-size: 1.16rem;
}
.hero > div > p:last-child {
max-width: 52rem;
margin-bottom: 0;
color: var(--ink-muted);
}
.eyebrow {
margin-bottom: 0.28rem;
color: var(--accent-dark);
font-size: 0.74rem;
font-weight: 850;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.privacy-pill,
.count-pill {
flex: none;
border: 1px solid color-mix(in srgb, var(--success) 48%, var(--line));
border-radius: 999px;
padding: 0.48rem 0.72rem;
color: var(--success);
font-size: 0.8rem;
font-weight: 800;
}
.panel {
margin-bottom: 0.85rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--surface);
box-shadow: 0 0.45rem 1.4rem color-mix(in srgb, #151126 7%, transparent);
padding: 1rem;
}
.panel-heading {
align-items: center;
justify-content: space-between;
gap: 0.85rem;
margin-bottom: 0.85rem;
}
.panel-heading p {
margin: 0.2rem 0 0;
color: var(--ink-muted);
font-size: 0.88rem;
}
.file-button {
position: relative;
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.file-button input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.source-grid,
.option-grid {
align-items: end;
flex-wrap: wrap;
gap: 0.65rem;
margin-bottom: 0.72rem;
}
.source-grid label,
.option-grid label,
.panel-heading label {
display: grid;
gap: 0.28rem;
min-width: 10rem;
color: var(--ink-muted);
font-size: 0.8rem;
font-weight: 750;
}
.source-grid label {
flex: 1 1 14rem;
}
.option-grid label {
flex: 1 1 10rem;
}
.primary-button {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
:root[data-toolbox-theme="dark"] .primary-button {
color: #17131f;
}
.tabs {
gap: 0.35rem;
overflow-x: auto;
margin: 0 0 0.85rem;
padding: 0.1rem 0;
}
.tabs button {
white-space: nowrap;
}
.tabs button[aria-pressed="true"] {
border-color: var(--accent);
background: var(--surface-muted);
color: var(--accent-dark);
}
.workspace-grid {
align-items: stretch;
gap: 0.85rem;
}
.workspace-grid > * {
min-width: 0;
flex: 1 1 0;
}
.table-scroll {
max-width: 100%;
overflow: auto;
border: 1px solid var(--line);
border-radius: 0.8rem;
}
.table-scroll:focus-visible {
outline: 3px solid color-mix(in srgb, var(--accent) 36%, transparent);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 0.55rem;
text-align: left;
vertical-align: top;
}
thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--surface-muted);
white-space: nowrap;
}
tbody tr:last-child > * {
border-bottom: 0;
}
.field-table {
min-width: 70rem;
}
.field-table input,
.field-table select {
min-width: 8rem;
}
.flag-cell label {
display: flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.flag-cell label + label {
margin-top: 0.4rem;
}
.flag-cell input {
width: auto;
min-height: 0;
}
.range-inputs {
gap: 0.35rem;
}
.range-inputs input {
min-width: 6rem;
}
.icon-button {
min-height: 2.3rem;
padding: 0.4rem 0.62rem;
font-size: 0.78rem;
}
.danger {
color: var(--danger);
}
.records {
max-height: 42rem;
}
.records td {
max-width: 18rem;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.export-panel textarea {
min-height: 30rem;
}
.violation-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
gap: 0.55rem;
margin: 0;
padding: 0;
list-style: none;
}
.violation-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
border: 1px solid color-mix(in srgb, var(--danger) 38%, var(--line));
border-radius: 0.7rem;
padding: 0.58rem;
}
.violation-list strong {
color: var(--danger);
font-size: 0.78rem;
}
.warning,
.error,
.success,
.empty {
margin: 0.65rem 0 0;
font-size: 0.88rem;
}
.warning {
color: var(--warning);
}
.error {
border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--line));
border-radius: 0.72rem;
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
padding: 0.66rem;
color: var(--danger);
}
.success {
color: var(--success);
}
.empty {
color: var(--ink-muted);
}
.top-gap {
margin-top: 0.75rem;
}
.preset-row,
.action-row {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.preset-row {
margin-bottom: 0.85rem;
}
.work-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(20rem, 0.82fr);
align-items: start;
gap: 0.85rem;
}
.work-grid > * {
min-width: 0;
}
.input-panel > label,
.predicate-panel > label,
.compact-grid label {
display: grid;
gap: 0.3rem;
margin-bottom: 0.7rem;
color: var(--ink-muted);
font-size: 0.8rem;
font-weight: 750;
}
.input-panel > label:first-of-type {
max-width: 15rem;
}
.compact-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(6rem, 0.32fr);
gap: 0.55rem;
}
.compact-grid label:first-child:last-child,
.compact-grid label:nth-child(3) {
grid-column: 1 / -1;
}
.check-row {
display: flex !important;
grid-template-columns: auto 1fr;
align-items: center;
flex-direction: row;
}
.check-row input {
width: auto;
min-height: 0;
}
.limits {
margin-top: 0.85rem;
}
.running {
margin: 0.75rem 0 0;
color: var(--accent-dark);
font-weight: 750;
}
.result-panel textarea {
min-height: 18rem;
}
.step-list {
max-height: 18rem;
overflow: auto;
padding-left: 1.5rem;
color: var(--ink-muted);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.8rem;
}
.selection-grid,
.explorer-grid,
.lab-grid {
display: grid;
gap: 0.75rem;
}
.selection-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 0.85rem;
}
.selection-grid label,
.lab-grid label {
display: grid;
gap: 0.3rem;
color: var(--ink-muted);
font-size: 0.8rem;
font-weight: 750;
}
.explorer-grid {
grid-template-columns: minmax(0, 1.45fr) minmax(15rem, 0.55fr);
align-items: center;
margin-bottom: 0.9rem;
}
.format-graph {
width: 100%;
max-height: 24rem;
border: 1px solid var(--line);
border-radius: 0.85rem;
background: var(--surface-muted);
}
.format-graph line {
stroke: color-mix(in srgb, var(--ink-muted) 52%, transparent);
stroke-width: 1.3;
}
.format-graph marker path {
fill: var(--ink-muted);
}
.format-graph circle {
fill: var(--surface);
stroke: var(--line);
stroke-width: 2;
}
.format-graph text {
fill: currentColor;
font-size: 14px;
font-weight: 800;
}
.format-graph .source-node circle {
fill: color-mix(in srgb, var(--accent) 14%, var(--surface));
stroke: var(--accent);
stroke-width: 4;
}
.format-graph .target-node circle {
stroke: var(--success);
stroke-width: 4;
}
.property-list {
display: grid;
gap: 0.42rem;
}
.property-list label {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.86rem;
}
.property-list input {
width: auto;
min-height: 0;
}
.route-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
gap: 0.6rem;
margin-bottom: 0.8rem;
}
.route-card {
display: grid;
align-content: start;
gap: 0.38rem;
min-width: 0;
text-align: left;
}
.route-card[aria-pressed="true"] {
border-color: var(--accent);
background: var(--surface-muted);
box-shadow: inset 0 0 0 1px var(--accent);
}
.route-card > span:not(.route-statuses) {
color: var(--ink-muted);
font-size: 0.78rem;
}
.route-statuses {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.route-statuses small,
.status {
border-radius: 999px;
padding: 0.22rem 0.42rem;
font-size: 0.7rem;
font-weight: 750;
}
.expected-preserved,
.measured-preserved {
background: color-mix(in srgb, var(--success) 13%, var(--surface));
color: var(--success);
}
.conditional,
.not-tested {
background: color-mix(in srgb, var(--warning) 13%, var(--surface));
color: var(--warning);
}
.expected-lost,
.measured-changed {
background: color-mix(in srgb, var(--danger) 13%, var(--surface));
color: var(--danger);
}
.unknown {
background: var(--surface-muted);
color: var(--ink-muted);
}
.lab-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
margin-bottom: 1rem;
}
.lab-grid textarea {
min-height: 25rem;
}
.evidence-table {
margin-top: 0.8rem;
}
.notice-list {
margin: 0.8rem 0 0;
padding-left: 1.3rem;
color: var(--warning);
font-size: 0.84rem;
}
details {
margin-top: 0.7rem;
}
summary {
cursor: pointer;
font-weight: 750;
}
.loading,
.fatal {
width: min(100%, 90rem);
margin: 2rem auto;
padding: 1rem;
}
.help-dialog {
width: min(38rem, calc(100vw - 2rem));
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--surface);
color: inherit;
padding: 1rem;
}
.help-dialog::backdrop {
background: rgb(16 14 25 / 58%);
}
.dialog-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
@media (max-width: 860px) {
.hero,
.workspace-grid {
flex-direction: column;
}
.privacy-pill {
align-self: flex-start;
}
.workspace-grid > * {
width: 100%;
}
.panel-heading {
align-items: flex-start;
flex-wrap: wrap;
}
.work-grid {
grid-template-columns: 1fr;
}
.selection-grid,
.explorer-grid,
.lab-grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition: none !important;
}
}
+8
View File
@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
localStorage.clear();
});
+48
View File
@@ -0,0 +1,48 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.format-lab",
"name": "Format Lab",
"version": "0.1.0",
"description": "Explore conversion paths and measure information loss locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["data", "developer", "media"],
"tags": [
"conversion",
"round-trip",
"information-loss",
"formats",
"csv",
"json"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": false,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": true,
"telemetry": false,
"label": "Inputs and conversion evidence stay in this browser; nothing is uploaded."
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/format-lab",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/format-lab"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import source from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(source));
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = "0.1.0";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />