ZDDC/zddc/internal/handler/tables.html

5845 lines
241 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Table</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxZTNhNWYiLz4KICA8ZyBmaWxsPSIjZmZmIj4KICAgIDxyZWN0IHg9IjE0IiB5PSIxOCIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iNDMsMjUgNTAsMjUgMjEsNDMgMTQsNDMiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI0MyIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICA8L2c+Cjwvc3ZnPgo=">
<style>
/* ==========================================================================
ZDDC Shared Base — single source of truth for tokens and primitives
Included first by every tool's build.sh via ../shared/base.css
========================================================================== */
/* ── CSS custom properties ────────────────────────────────────────────────── */
:root {
/* Brand / accent (matches zddc.varasys.io website --accent) */
--primary: #2a5a8a;
--primary-hover: #1d4060;
--primary-active: #163352;
--primary-light: #e8f0f7;
/* Semantic colours */
--success: #28a745;
--warning: #d97706;
--danger: #dc3545;
--info: #17a2b8;
/* Backgrounds */
--bg: #ffffff;
--bg-secondary: #f8f9fa;
--bg-hover: #f0f4f8;
--bg-selected: var(--primary-light);
/* Text */
--text: #212529;
--text-muted: #6c757d;
--text-light: #ffffff;
/* Borders */
--border: #dee2e6;
--border-dark: #adb5bd;
/* Shape */
--radius: 4px;
/* Typography */
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
}
/* ── Dark mode tokens ─────────────────────────────────────────────────────── */
/* Applied via: OS preference (auto) or [data-theme="dark"] on <html> */
/* The [data-theme="light"] selector locks light mode regardless of OS pref. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
}
/* Manual dark override — wins over media query */
[data-theme="dark"] {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
/* ── Reset ────────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Base document ────────────────────────────────────────────────────────── */
html, body {
height: 100%;
font-family: var(--font);
font-size: 16px;
line-height: 1.5;
color: var(--text);
background-color: var(--bg-secondary);
}
/* ── Typography ───────────────────────────────────────────────────────────── */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.2;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ── Utility ──────────────────────────────────────────────────────────────── */
.hidden {
display: none !important;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Scrollbars (webkit) ──────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 7px;
height: 7px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
/* ── Button primitive ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 0.85rem;
font-family: var(--font);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.4;
text-align: center;
text-decoration: none;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
border: 1px solid transparent;
border-radius: var(--radius);
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s, color 0.15s;
background: var(--bg-secondary);
color: var(--text);
}
.btn:disabled,
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.btn:not(:disabled):hover {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
.btn:not(:disabled):active {
box-shadow: none;
}
/* Variants */
.btn-primary {
background: var(--primary);
color: var(--text-light);
border-color: var(--primary);
}
.btn-primary:not(:disabled):hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--text-light);
}
.btn-primary:not(:disabled):active {
background: var(--primary-active);
border-color: var(--primary-active);
}
.btn-secondary {
background: var(--bg);
color: var(--text);
border-color: var(--border);
}
.btn-secondary:not(:disabled):hover {
background: var(--bg-secondary);
}
/* Subdued / de-emphasized variant.
Used on the "Add Local Directory" button when a tool is operating
in server (online) mode — the local-dir affordance is still
available but visually quieter, since the typical user already
has the directory loaded from the server. */
.btn.btn--subtle {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
box-shadow: none;
font-weight: normal;
}
.btn.btn--subtle:not(:disabled):hover {
color: var(--text);
background: var(--bg-secondary);
}
.btn-success {
background: var(--success);
color: var(--text-light);
border-color: var(--success);
}
.btn-danger {
background: var(--danger);
color: var(--text-light);
border-color: var(--danger);
}
/* Sizes */
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.btn-lg {
padding: 0.6rem 1.4rem;
font-size: 1rem;
}
.btn-link {
background: transparent;
border-color: transparent;
color: var(--primary);
padding-left: 0;
padding-right: 0;
}
.btn-link:not(:disabled):hover {
text-decoration: underline;
box-shadow: none;
}
/* ── App header chrome ────────────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
/* Tool name inside the header */
.app-header__title {
font-size: 17px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.01em;
white-space: nowrap;
}
/* Brand logo — sits left of the title in every tool's app-header.
Self-contained: the SVG provides its own dark blue rounded background,
so no extra wrapper styling is needed. */
.app-header__logo {
width: 26px;
height: 26px;
flex-shrink: 0;
display: block;
}
/* ── Build timestamp ──────────────────────────────────────────────────────── */
.build-timestamp {
font-size: 0.55rem;
color: var(--text-muted);
opacity: 0.7;
font-weight: 300;
white-space: nowrap;
padding-top: 0.15rem;
}
/* Title + timestamp stacked vertically on the left side of the header */
.header-title-group {
display: flex;
flex-direction: column;
gap: 0;
line-height: 1;
}
/* ── Icon buttons (help, theme, refresh) ─────────────────────────────────── */
/* Square, centered — overrides the asymmetric text-button padding/line-height */
#help-btn,
#theme-btn,
#refreshHeaderBtn {
width: 2rem;
height: 2rem;
padding: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
/* The refresh ⟳ glyph renders slightly smaller than ◐ / ? — bump to match. */
#refreshHeaderBtn {
font-size: 1.1rem;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
#theme-btn,
#help-btn {
font-size: 1rem;
}
/* ── Help panel (shared slide-out drawer) ─────────────────────────────────── */
/* Used by all four tools. Toggle open/close via shared/help.js. */
.help-panel {
position: fixed;
top: 0;
right: 0;
width: min(420px, 85vw);
height: 100vh;
z-index: 1000;
background: var(--bg);
border-left: 1px solid var(--border);
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
}
.help-panel:not([hidden]) {
transform: translateX(0);
}
.help-panel[hidden] {
display: flex;
transform: translateX(100%);
pointer-events: none;
}
.help-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg);
}
.help-panel__title {
font-size: 1rem;
font-weight: 700;
color: var(--text);
margin: 0;
}
.help-panel__close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.35rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.help-panel__close:hover {
color: var(--text);
background: var(--bg-secondary);
}
.help-panel__body {
flex: 1;
overflow-y: auto;
padding: 1rem 1rem 2rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
.help-panel__body h3 {
font-size: 0.95rem;
font-weight: 700;
margin: 1.25rem 0 0.35rem;
color: var(--text);
border-bottom: 1px solid var(--border);
padding-bottom: 0.15rem;
}
.help-panel__body h3:first-child {
margin-top: 0;
}
.help-panel__body h4 {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 1.25rem 0 0.3rem;
padding-left: 0.5rem;
border-left: 3px solid var(--border-dark);
color: var(--text-muted);
}
.help-panel__body p {
margin: 0 0 0.5rem;
}
.help-panel__body ol,
.help-panel__body ul {
padding-left: 1.5rem;
margin: 0.3rem 0 0.5rem;
}
.help-panel__body li {
margin-bottom: 0.3rem;
}
.help-panel__body dl {
margin: 0.3rem 0;
}
.help-panel__body dt {
font-weight: 600;
color: var(--text);
}
.help-panel__body dd {
margin: 0 0 0.5rem 1rem;
color: var(--text-muted);
}
.help-panel__body code {
font-family: var(--font-mono);
font-size: 0.8em;
background: var(--bg-secondary);
padding: 0.1em 0.3em;
border-radius: 3px;
}
.help-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: var(--radius);
vertical-align: middle;
letter-spacing: 0.02em;
}
.help-badge--draft {
color: #2563eb;
background: #eff6ff;
}
.help-badge--published {
color: #7c3aed;
background: #f5f3ff;
}
/* Shrink main content when help panel is open */
body.help-open .app-header {
margin-right: min(420px, 85vw);
}
/* ── Column filter inputs (shared across archive, classifier, transmittal) ─── */
.column-filter {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 0.25rem;
padding: 0.2rem 0.4rem;
font-size: 0.8rem;
font-family: var(--font);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
transition: border-color 0.15s;
}
.column-filter:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 1px rgba(42, 90, 138, 0.35);
}
.column-filter::placeholder {
color: var(--text-muted);
}
/* shared/toast.css — single-toast notification styles paired with
shared/toast.js. Uses BEM-ish .zddc-toast prefix to avoid collisions
with tool-local .toast classes; the old classifier rules can stay
alongside until this file is concatenated above them in the build. */
.zddc-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
font-size: 0.875rem;
cursor: pointer;
animation: zddc-toast-in 0.3s ease-out;
}
.zddc-toast--success { border-left: 4px solid var(--success); }
.zddc-toast--error { border-left: 4px solid var(--danger); }
.zddc-toast--info { border-left: 4px solid var(--info); }
.zddc-toast--warning { border-left: 4px solid var(--warning); }
.zddc-toast--fade {
animation: zddc-toast-out 0.3s ease-out forwards;
}
@keyframes zddc-toast-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes zddc-toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
Sits as a sibling immediately under .app-header (mounted by JS).
Rendered only in online mode when a project segment is in the URL. */
.zddc-stage-strip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
line-height: 1.3;
flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
}
.zddc-stage-strip__project {
color: var(--text);
font-weight: 600;
margin-right: 0.15rem;
}
.zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
}
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
inherits the logo's box and adds a subtle hover/focus affordance
so it reads as clickable without altering the logo's visual weight. */
.app-header__logo-link {
display: inline-flex;
align-items: center;
text-decoration: none;
border-radius: var(--radius);
transition: opacity 0.15s, box-shadow 0.15s;
}
.app-header__logo-link:hover .app-header__logo,
.app-header__logo-link:focus-visible .app-header__logo {
opacity: 0.82;
}
.app-header__logo-link:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
.table-main {
padding: var(--spacing-md);
max-width: 100%;
}
.table-description {
margin: 0 0 var(--spacing-md);
color: var(--color-text-muted);
font-size: 0.95rem;
}
.table-status {
margin: 0 0 var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-warning, #fff8e6);
border: 1px solid var(--color-border, #d6cfa3);
border-radius: var(--radius-sm, 4px);
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-md);
margin: 0 0 var(--spacing-sm);
}
.table-toolbar__left,
.table-toolbar__right {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
#table-add-row {
text-decoration: none;
}
.table-rowcount {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.table-scroll {
overflow: auto;
max-height: calc(100vh - 200px);
border: 1px solid var(--color-border, #d8d8d8);
border-radius: var(--radius-sm, 4px);
}
.zddc-table {
border-collapse: collapse;
width: 100%;
font-size: 0.95rem;
}
.zddc-table thead {
position: sticky;
top: 0;
z-index: 2;
background: var(--color-bg-elevated, #f5f5f5);
}
.zddc-table__title-row .zddc-table__th {
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
font-weight: 600;
border-bottom: 1px solid var(--color-border, #d8d8d8);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.zddc-table__title-row .zddc-table__th:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
}
.zddc-table__filter-row .zddc-table__filter-cell {
padding: 4px var(--spacing-sm);
border-bottom: 1px solid var(--color-border, #d8d8d8);
background: var(--color-bg-elevated, #f5f5f5);
}
.zddc-table__filter-text,
.zddc-table__filter-enum {
width: 100%;
box-sizing: border-box;
padding: 2px 4px;
font-size: 0.85rem;
border: 1px solid var(--color-border, #d0d0d0);
border-radius: 3px;
background: var(--color-bg, #fff);
color: var(--color-text, #111);
}
.zddc-table__filter-enum {
min-height: 1.8em;
}
.zddc-table__row:nth-child(even) {
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
}
.zddc-table__row--readonly {
color: var(--color-text-muted);
}
.zddc-table__cell {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--color-border-soft, rgba(0, 0, 0, 0.06));
vertical-align: top;
cursor: cell;
/* Hide the browser's default outline; the grid pattern renders
its own selection chrome via the --selected class. */
outline: none;
}
/* Currently-selected cell — Excel-style focus ring. The 2px outset
border doesn't push surrounding cells around because outline is
used instead of border. */
.zddc-table__cell--selected {
outline: 2px solid var(--color-accent, #2868c8);
outline-offset: -2px;
background: var(--color-bg-selected, rgba(40, 104, 200, 0.08));
}
/* Cells in the multi-cell range get a fainter highlight; the focus
cell (the one with --selected) stays brighter so the anchor /
focus distinction is visible. */
.zddc-table__cell--in-range:not(.zddc-table__cell--selected) {
background: var(--color-bg-range, rgba(40, 104, 200, 0.05));
}
/* Inline cell-editor input: occupies the cell verbatim, no border so
it visually replaces the cell text. The selected outline on the
surrounding td still shows. */
.zddc-table__cell-input {
width: 100%;
box-sizing: border-box;
padding: 0;
margin: 0;
border: none;
background: var(--color-bg, #fff);
color: var(--color-text, #111);
font: inherit;
outline: none;
}
/* Row-save state markers (Phase 3). The first cell of the row gets a
left-border swatch; the row tooltip on hover surfaces the state.
Colors track the state's urgency: dirty (subtle), saving (info),
queued (warm), invalid/stale (warning), errored (alert). */
.zddc-table__row--dirty td:first-child { box-shadow: inset 3px 0 0 var(--color-info, #4a90e2); }
.zddc-table__row--saving td:first-child { box-shadow: inset 3px 0 0 var(--color-muted, #888); }
.zddc-table__row--queued td:first-child { box-shadow: inset 3px 0 0 var(--color-warm, #d4a017); }
.zddc-table__row--stale td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); background: var(--color-bg-warning, rgba(232, 163, 61, 0.06)); }
.zddc-table__row--invalid td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); }
.zddc-table__row--errored td:first-child { box-shadow: inset 3px 0 0 var(--color-error, #c14242); background: var(--color-bg-error, rgba(193, 66, 66, 0.06)); }
/* Per-cell invalid marker — small red corner triangle, Excel-style.
The hover tooltip carries the validation message via title attr. */
.zddc-table__cell--invalid {
position: relative;
}
.zddc-table__cell--invalid::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 6px 6px 0;
border-color: transparent var(--color-error, #c14242) transparent transparent;
}
/* Status bar (table-status) when used as the stale-row prompt host. */
.table-status.table-status--prompt {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-warning, rgba(232, 163, 61, 0.08));
border: 1px solid var(--color-warning, #e8a33d);
border-radius: var(--radius-sm, 4px);
margin-bottom: var(--spacing-sm);
color: var(--color-text, #111);
}
.table-empty {
padding: var(--spacing-lg) var(--spacing-md);
text-align: center;
color: var(--color-text-muted);
font-style: italic;
}
/* form/ — ZDDC generic form renderer.
Pulls theme tokens from shared/base.css; only adds form-specific layout. */
.form-main {
max-width: 800px;
margin: 1.5rem auto;
padding: 0 1rem 4rem;
}
.form-status {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 4px;
border: 1px solid var(--color-border);
}
.form-status.is-error {
background: var(--color-bg-alt);
border-color: #c43;
color: #c43;
}
.form-status.is-success {
background: var(--color-bg-alt);
border-color: #283;
color: #283;
}
.form-root {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field__label {
font-weight: 600;
font-size: 0.95rem;
}
.form-field__label .required-mark {
color: #c43;
margin-left: 0.15rem;
}
.form-field__description {
font-size: 0.85rem;
color: var(--color-text-muted, #666);
}
.form-field__error {
font-size: 0.85rem;
color: #c43;
margin-top: 0.15rem;
}
.form-field__help {
font-size: 0.8rem;
color: var(--color-text-muted, #666);
font-style: italic;
}
.form-field__input,
.form-field__textarea,
.form-field__select {
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg, #fff);
color: var(--color-text, #111);
font: inherit;
width: 100%;
box-sizing: border-box;
}
.form-field__textarea {
min-height: 5em;
resize: vertical;
}
.form-field__input:focus,
.form-field__textarea:focus,
.form-field__select:focus {
outline: 2px solid var(--color-primary, #1e3a5f);
outline-offset: -1px;
}
.form-field--invalid .form-field__input,
.form-field--invalid .form-field__textarea,
.form-field--invalid .form-field__select {
border-color: #c43;
}
.form-field__radio-group,
.form-field__checkbox-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field__radio-group label,
.form-field__checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 400;
cursor: pointer;
}
.form-fieldset {
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-fieldset__legend {
font-weight: 600;
padding: 0 0.4rem;
}
.form-array {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-array__row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.5rem;
background: var(--color-bg-alt, #f6f6f8);
}
.form-array__row-body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-array__row-actions {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-array__add {
align-self: flex-start;
}
.form-actions {
margin-top: 1.5rem;
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
border: 1px solid var(--color-border);
background: var(--color-bg, #fff);
color: var(--color-text, #111);
cursor: pointer;
font: inherit;
}
.btn:hover {
background: var(--color-bg-alt, #f6f6f8);
}
.btn-primary {
background: var(--color-primary, #1e3a5f);
color: #fff;
border-color: var(--color-primary, #1e3a5f);
}
.btn-primary:hover {
filter: brightness(1.1);
}
.btn-small {
padding: 0.2rem 0.5rem;
font-size: 0.85rem;
}
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
/* Standalone welcome — shown when form.html is opened directly (no server-injected #form-context). */
.form-welcome {
max-width: 36rem;
margin: 2rem auto;
padding: 1.5rem 1.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.form-welcome h2 {
margin-bottom: 0.5rem;
font-size: 1.25rem;
}
.form-welcome h3 {
margin: 1rem 0 0.35rem;
font-size: 0.95rem;
}
.form-welcome p { margin-bottom: 0.75rem; line-height: 1.5; }
.form-welcome ol { margin: 0 0 0.75rem 1.25rem; }
.form-welcome li { margin-bottom: 0.35rem; }
.form-welcome code {
font-family: var(--font-mono);
font-size: 0.85em;
background: var(--bg-secondary);
padding: 0.05em 0.3em;
border-radius: 3px;
}
</style>
</head>
<body>
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-10 · locust-boat-sage</span></span>
</div>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div>
</header>
<!-- Table mode: shown for /<dir>/table.html requests. -->
<main id="table-mode" class="table-main" hidden>
<div id="table-description" class="table-description" hidden></div>
<div id="table-status" class="table-status" hidden></div>
<div class="table-toolbar" id="table-toolbar">
<div class="table-toolbar__left">
<span id="table-rowcount" class="table-rowcount" aria-live="polite"></span>
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
</div>
<div class="table-toolbar__right">
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
</div>
</div>
<div class="table-scroll">
<table id="table-root" class="zddc-table" aria-describedby="table-description">
<thead></thead>
<tbody></tbody>
</table>
</div>
<div id="table-empty" class="table-empty" hidden>No rows match the current filters.</div>
</main>
<!-- Form mode: shown for /<dir>/form.html and /<dir>/<id>.yaml.html
requests. Same bundle ships both modes so a row's "+ Add row"
and click-to-edit reuse the table tool's spec, validator, and
file-IO instead of duplicating them in a separate form HTML. -->
<main id="form-mode" class="form-main" hidden>
<div id="form-status" class="form-status" hidden></div>
<form id="form-root" class="form-root" novalidate></form>
<div class="form-actions">
<button type="button" id="submit-btn" class="btn btn-primary">Submit</button>
</div>
</main>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Table</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is this table?</h3>
<p>The directory you opened — say <code>archive/Acme/mdl/</code>
<em>is</em> the table. <code>table.yaml</code> describes the
columns; <code>form.yaml</code> describes the row-edit form
schema; every other <code>.yaml</code> file in the directory
is one row. Copying the directory anywhere takes the whole
table (spec + form + every row) with it.</p>
<h3>Editing cells</h3>
<p>Click a cell to select it. Then:</p>
<dl>
<dt><kbd></kbd> / <kbd></kbd> / <kbd></kbd> / <kbd></kbd></dt>
<dd>Move selection. Hold <kbd>Shift</kbd> to extend a range.</dd>
<dt><kbd>Tab</kbd> / <kbd>Shift+Tab</kbd></dt>
<dd>Move right / left, wrap to next / previous row.</dd>
<dt><kbd>Enter</kbd> / <kbd>F2</kbd> / double-click / typing</dt>
<dd>Enter edit mode. Typing replaces the cell value; the
others keep it.</dd>
<dt><kbd>Enter</kbd> in edit mode</dt>
<dd>Commit and move down.</dd>
<dt><kbd>Tab</kbd> in edit mode</dt>
<dd>Commit and move right.</dd>
<dt><kbd>Esc</kbd></dt>
<dd>Cancel the edit; restore the prior value.</dd>
<dt><kbd>Delete</kbd> / <kbd>Backspace</kbd></dt>
<dd>Clear every cell in the current selection.</dd>
<dt><kbd>Ctrl+D</kbd> / <kbd>Ctrl+R</kbd></dt>
<dd>Fill the top row down / left column right through the
selected range.</dd>
<dt><kbd>Ctrl+C</kbd> / <kbd>Ctrl+V</kbd></dt>
<dd>Copy / paste — interoperates with Excel and Google
Sheets via tab-separated values.</dd>
<dt><kbd>Ctrl+Z</kbd></dt>
<dd>Undo the last edit (one history per session).</dd>
</dl>
<p>Edits save automatically when you move to a different row.
A small left-edge swatch on the row indicates state:
<strong>blue</strong> = unsaved, <strong>amber</strong> = the
server flagged a validation error, <strong>orange</strong> =
someone else changed this row since you loaded it (you'll
get a prompt with <em>Use mine</em> / <em>Reload</em>).</p>
<h3>Sorting</h3>
<p>Click a column header to sort by that column. Click again to
toggle direction. <kbd>Shift</kbd>-click another header to
add a secondary sort key.</p>
<h3>Filtering</h3>
<p>Type in the box under a column header to filter rows whose
value contains your text (case-insensitive). Same filter UI
for every column.</p>
<h3>Customizing the columns</h3>
<p>The default Master Deliverables List has columns for every
component of a tracking number
(<code>originator</code>, <code>phase</code>,
<code>project</code>, <code>area</code>,
<code>discipline</code>, <code>type</code>,
<code>sequence</code>, <code>suffix</code>) plus deliverable
metadata. To customize, drop your own
<code>table.yaml</code> (and matching
<code>form.yaml</code>) into this directory:</p>
<pre><code>archive/&lt;party&gt;/mdl/
table.yaml ← columns + sort/filter defaults
form.yaml ← per-row schema (JSON Schema)
&lt;id&gt;.yaml ... ← rows</code></pre>
<p>Operator-supplied files override the embedded defaults.
Hide a column by omitting it from <code>columns:</code>;
add a column by appending one (and adding the matching
property in <code>form.yaml</code>'s
<code>schema.properties</code>). The same pattern works
for any directory — <code>&lt;dir&gt;/table.html</code>
is automatically a table whenever
<code>&lt;dir&gt;/table.yaml</code> exists.</p>
<h3>Permissions</h3>
<p>Whether a row is editable depends on the cascading
<code>.zddc</code> permissions for the directory. Rows
in <code>Issued</code> or <code>Received</code> archives
are read-only by design (WORM).</p>
<h3>Header buttons</h3>
<dl>
<dt>◐ Theme</dt>
<dd>Cycle auto / light / dark.</dd>
<dt>? Help</dt>
<dd>This panel. Press <kbd>Esc</kbd> to close.</dd>
</dl>
</div>
</aside>
<!--
Server injects the table context here on render. Shape:
{
"title": "Optional page title override",
"description": "Optional description shown above the table",
"columns": [{field, title, width?, format?, filter?, sort?, enum?}],
"rows": [{url, data, editable}],
"defaults": {sort?: [{field, dir}], filter?: {field: value}}
}
-->
<script id="table-context" type="application/json">{}</script>
<!--
Form mode context — server injects this for /<dir>/form.html and
/<dir>/<id>.yaml.html. Empty in table-mode renders.
-->
<script id="form-context" type="application/json">{}</script>
<script>
/*! js-yaml 4.1.0 https://github.com/nodeca/js-yaml @license MIT */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).jsyaml={})}(this,(function(e){"use strict";function t(e){return null==e}var n={isNothing:t,isObject:function(e){return"object"==typeof e&&null!==e},toArray:function(e){return Array.isArray(e)?e:t(e)?[]:[e]},repeat:function(e,t){var n,i="";for(n=0;n<t;n+=1)i+=e;return i},isNegativeZero:function(e){return 0===e&&Number.NEGATIVE_INFINITY===1/e},extend:function(e,t){var n,i,r,o;if(t)for(n=0,i=(o=Object.keys(t)).length;n<i;n+=1)e[r=o[n]]=t[r];return e}};function i(e,t){var n="",i=e.reason||"(unknown reason)";return e.mark?(e.mark.name&&(n+='in "'+e.mark.name+'" '),n+="("+(e.mark.line+1)+":"+(e.mark.column+1)+")",!t&&e.mark.snippet&&(n+="\n\n"+e.mark.snippet),i+" "+n):i}function r(e,t){Error.call(this),this.name="YAMLException",this.reason=e,this.mark=t,this.message=i(this,!1),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=(new Error).stack||""}r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r.prototype.toString=function(e){return this.name+": "+i(this,e)};var o=r;function a(e,t,n,i,r){var o="",a="",l=Math.floor(r/2)-1;return i-t>l&&(t=i-l+(o=" ... ").length),n-i>l&&(n=i+l-(a=" ...").length),{str:o+e.slice(t,n).replace(/\t/g,"→")+a,pos:i-t+o.length}}function l(e,t){return n.repeat(" ",t-e.length)+e}var c=function(e,t){if(t=Object.create(t||null),!e.buffer)return null;t.maxLength||(t.maxLength=79),"number"!=typeof t.indent&&(t.indent=1),"number"!=typeof t.linesBefore&&(t.linesBefore=3),"number"!=typeof t.linesAfter&&(t.linesAfter=2);for(var i,r=/\r?\n|\r|\0/g,o=[0],c=[],s=-1;i=r.exec(e.buffer);)c.push(i.index),o.push(i.index+i[0].length),e.position<=i.index&&s<0&&(s=o.length-2);s<0&&(s=o.length-1);var u,p,f="",d=Math.min(e.line+t.linesAfter,c.length).toString().length,h=t.maxLength-(t.indent+d+3);for(u=1;u<=t.linesBefore&&!(s-u<0);u++)p=a(e.buffer,o[s-u],c[s-u],e.position-(o[s]-o[s-u]),h),f=n.repeat(" ",t.indent)+l((e.line-u+1).toString(),d)+" | "+p.str+"\n"+f;for(p=a(e.buffer,o[s],c[s],e.position,h),f+=n.repeat(" ",t.indent)+l((e.line+1).toString(),d)+" | "+p.str+"\n",f+=n.repeat("-",t.indent+d+3+p.pos)+"^\n",u=1;u<=t.linesAfter&&!(s+u>=c.length);u++)p=a(e.buffer,o[s+u],c[s+u],e.position-(o[s]-o[s+u]),h),f+=n.repeat(" ",t.indent)+l((e.line+u+1).toString(),d)+" | "+p.str+"\n";return f.replace(/\n$/,"")},s=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],u=["scalar","sequence","mapping"];var p=function(e,t){if(t=t||{},Object.keys(t).forEach((function(t){if(-1===s.indexOf(t))throw new o('Unknown option "'+t+'" is met in definition of "'+e+'" YAML type.')})),this.options=t,this.tag=e,this.kind=t.kind||null,this.resolve=t.resolve||function(){return!0},this.construct=t.construct||function(e){return e},this.instanceOf=t.instanceOf||null,this.predicate=t.predicate||null,this.represent=t.represent||null,this.representName=t.representName||null,this.defaultStyle=t.defaultStyle||null,this.multi=t.multi||!1,this.styleAliases=function(e){var t={};return null!==e&&Object.keys(e).forEach((function(n){e[n].forEach((function(e){t[String(e)]=n}))})),t}(t.styleAliases||null),-1===u.indexOf(this.kind))throw new o('Unknown kind "'+this.kind+'" is specified for "'+e+'" YAML type.')};function f(e,t){var n=[];return e[t].forEach((function(e){var t=n.length;n.forEach((function(n,i){n.tag===e.tag&&n.kind===e.kind&&n.multi===e.multi&&(t=i)})),n[t]=e})),n}function d(e){return this.extend(e)}d.prototype.extend=function(e){var t=[],n=[];if(e instanceof p)n.push(e);else if(Array.isArray(e))n=n.concat(e);else{if(!e||!Array.isArray(e.implicit)&&!Array.isArray(e.explicit))throw new o("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");e.implicit&&(t=t.concat(e.implicit)),e.explicit&&(n=n.concat(e.explicit))}t.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(e.loadKind&&"scalar"!==e.loadKind)throw new o("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(e.multi)throw new o("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")})),n.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.")}));var i=Object.create(d.prototype);return i.implicit=(this.implicit||[]).concat(t),i.explicit=(this.explicit||[]).concat(n),i.compiledImplicit=f(i,"implicit"),i.compiledExplicit=f(i,"explicit"),i.compiledTypeMap=function(){var e,t,n={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}};function i(e){e.multi?(n.multi[e.kind].push(e),n.multi.fallback.push(e)):n[e.kind][e.tag]=n.fallback[e.tag]=e}for(e=0,t=arguments.length;e<t;e+=1)arguments[e].forEach(i);return n}(i.compiledImplicit,i.compiledExplicit),i};var h=d,g=new p("tag:yaml.org,2002:str",{kind:"scalar",construct:function(e){return null!==e?e:""}}),m=new p("tag:yaml.org,2002:seq",{kind:"sequence",construct:function(e){return null!==e?e:[]}}),y=new p("tag:yaml.org,2002:map",{kind:"mapping",construct:function(e){return null!==e?e:{}}}),b=new h({explicit:[g,m,y]});var A=new p("tag:yaml.org,2002:null",{kind:"scalar",resolve:function(e){if(null===e)return!0;var t=e.length;return 1===t&&"~"===e||4===t&&("null"===e||"Null"===e||"NULL"===e)},construct:function(){return null},predicate:function(e){return null===e},represent:{canonical:function(){return"~"},lowercase:function(){return"null"},uppercase:function(){return"NULL"},camelcase:function(){return"Null"},empty:function(){return""}},defaultStyle:"lowercase"});var v=new p("tag:yaml.org,2002:bool",{kind:"scalar",resolve:function(e){if(null===e)return!1;var t=e.length;return 4===t&&("true"===e||"True"===e||"TRUE"===e)||5===t&&("false"===e||"False"===e||"FALSE"===e)},construct:function(e){return"true"===e||"True"===e||"TRUE"===e},predicate:function(e){return"[object Boolean]"===Object.prototype.toString.call(e)},represent:{lowercase:function(e){return e?"true":"false"},uppercase:function(e){return e?"TRUE":"FALSE"},camelcase:function(e){return e?"True":"False"}},defaultStyle:"lowercase"});function w(e){return 48<=e&&e<=55}function k(e){return 48<=e&&e<=57}var C=new p("tag:yaml.org,2002:int",{kind:"scalar",resolve:function(e){if(null===e)return!1;var t,n,i=e.length,r=0,o=!1;if(!i)return!1;if("-"!==(t=e[r])&&"+"!==t||(t=e[++r]),"0"===t){if(r+1===i)return!0;if("b"===(t=e[++r])){for(r++;r<i;r++)if("_"!==(t=e[r])){if("0"!==t&&"1"!==t)return!1;o=!0}return o&&"_"!==t}if("x"===t){for(r++;r<i;r++)if("_"!==(t=e[r])){if(!(48<=(n=e.charCodeAt(r))&&n<=57||65<=n&&n<=70||97<=n&&n<=102))return!1;o=!0}return o&&"_"!==t}if("o"===t){for(r++;r<i;r++)if("_"!==(t=e[r])){if(!w(e.charCodeAt(r)))return!1;o=!0}return o&&"_"!==t}}if("_"===t)return!1;for(;r<i;r++)if("_"!==(t=e[r])){if(!k(e.charCodeAt(r)))return!1;o=!0}return!(!o||"_"===t)},construct:function(e){var t,n=e,i=1;if(-1!==n.indexOf("_")&&(n=n.replace(/_/g,"")),"-"!==(t=n[0])&&"+"!==t||("-"===t&&(i=-1),t=(n=n.slice(1))[0]),"0"===n)return 0;if("0"===t){if("b"===n[1])return i*parseInt(n.slice(2),2);if("x"===n[1])return i*parseInt(n.slice(2),16);if("o"===n[1])return i*parseInt(n.slice(2),8)}return i*parseInt(n,10)},predicate:function(e){return"[object Number]"===Object.prototype.toString.call(e)&&e%1==0&&!n.isNegativeZero(e)},represent:{binary:function(e){return e>=0?"0b"+e.toString(2):"-0b"+e.toString(2).slice(1)},octal:function(e){return e>=0?"0o"+e.toString(8):"-0o"+e.toString(8).slice(1)},decimal:function(e){return e.toString(10)},hexadecimal:function(e){return e>=0?"0x"+e.toString(16).toUpperCase():"-0x"+e.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}}),x=new RegExp("^(?:[-+]?(?:[0-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");var I=/^[-+]?[0-9]+e/;var S=new p("tag:yaml.org,2002:float",{kind:"scalar",resolve:function(e){return null!==e&&!(!x.test(e)||"_"===e[e.length-1])},construct:function(e){var t,n;return n="-"===(t=e.replace(/_/g,"").toLowerCase())[0]?-1:1,"+-".indexOf(t[0])>=0&&(t=t.slice(1)),".inf"===t?1===n?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:".nan"===t?NaN:n*parseFloat(t,10)},predicate:function(e){return"[object Number]"===Object.prototype.toString.call(e)&&(e%1!=0||n.isNegativeZero(e))},represent:function(e,t){var i;if(isNaN(e))switch(t){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===e)switch(t){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===e)switch(t){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(n.isNegativeZero(e))return"-0.0";return i=e.toString(10),I.test(i)?i.replace("e",".e"):i},defaultStyle:"lowercase"}),O=b.extend({implicit:[A,v,C,S]}),j=O,T=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),N=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");var F=new p("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:function(e){return null!==e&&(null!==T.exec(e)||null!==N.exec(e))},construct:function(e){var t,n,i,r,o,a,l,c,s=0,u=null;if(null===(t=T.exec(e))&&(t=N.exec(e)),null===t)throw new Error("Date resolve error");if(n=+t[1],i=+t[2]-1,r=+t[3],!t[4])return new Date(Date.UTC(n,i,r));if(o=+t[4],a=+t[5],l=+t[6],t[7]){for(s=t[7].slice(0,3);s.length<3;)s+="0";s=+s}return t[9]&&(u=6e4*(60*+t[10]+ +(t[11]||0)),"-"===t[9]&&(u=-u)),c=new Date(Date.UTC(n,i,r,o,a,l,s)),u&&c.setTime(c.getTime()-u),c},instanceOf:Date,represent:function(e){return e.toISOString()}});var E=new p("tag:yaml.org,2002:merge",{kind:"scalar",resolve:function(e){return"<<"===e||null===e}}),M="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r";var L=new p("tag:yaml.org,2002:binary",{kind:"scalar",resolve:function(e){if(null===e)return!1;var t,n,i=0,r=e.length,o=M;for(n=0;n<r;n++)if(!((t=o.indexOf(e.charAt(n)))>64)){if(t<0)return!1;i+=6}return i%8==0},construct:function(e){var t,n,i=e.replace(/[\r\n=]/g,""),r=i.length,o=M,a=0,l=[];for(t=0;t<r;t++)t%4==0&&t&&(l.push(a>>16&255),l.push(a>>8&255),l.push(255&a)),a=a<<6|o.indexOf(i.charAt(t));return 0===(n=r%4*6)?(l.push(a>>16&255),l.push(a>>8&255),l.push(255&a)):18===n?(l.push(a>>10&255),l.push(a>>2&255)):12===n&&l.push(a>>4&255),new Uint8Array(l)},predicate:function(e){return"[object Uint8Array]"===Object.prototype.toString.call(e)},represent:function(e){var t,n,i="",r=0,o=e.length,a=M;for(t=0;t<o;t++)t%3==0&&t&&(i+=a[r>>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]),r=(r<<8)+e[t];return 0===(n=o%3)?(i+=a[r>>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]):2===n?(i+=a[r>>10&63],i+=a[r>>4&63],i+=a[r<<2&63],i+=a[64]):1===n&&(i+=a[r>>2&63],i+=a[r<<4&63],i+=a[64],i+=a[64]),i}}),_=Object.prototype.hasOwnProperty,D=Object.prototype.toString;var U=new p("tag:yaml.org,2002:omap",{kind:"sequence",resolve:function(e){if(null===e)return!0;var t,n,i,r,o,a=[],l=e;for(t=0,n=l.length;t<n;t+=1){if(i=l[t],o=!1,"[object Object]"!==D.call(i))return!1;for(r in i)if(_.call(i,r)){if(o)return!1;o=!0}if(!o)return!1;if(-1!==a.indexOf(r))return!1;a.push(r)}return!0},construct:function(e){return null!==e?e:[]}}),q=Object.prototype.toString;var Y=new p("tag:yaml.org,2002:pairs",{kind:"sequence",resolve:function(e){if(null===e)return!0;var t,n,i,r,o,a=e;for(o=new Array(a.length),t=0,n=a.length;t<n;t+=1){if(i=a[t],"[object Object]"!==q.call(i))return!1;if(1!==(r=Object.keys(i)).length)return!1;o[t]=[r[0],i[r[0]]]}return!0},construct:function(e){if(null===e)return[];var t,n,i,r,o,a=e;for(o=new Array(a.length),t=0,n=a.length;t<n;t+=1)i=a[t],r=Object.keys(i),o[t]=[r[0],i[r[0]]];return o}}),R=Object.prototype.hasOwnProperty;var B=new p("tag:yaml.org,2002:set",{kind:"mapping",resolve:function(e){if(null===e)return!0;var t,n=e;for(t in n)if(R.call(n,t)&&null!==n[t])return!1;return!0},construct:function(e){return null!==e?e:{}}}),K=j.extend({implicit:[F,E],explicit:[L,U,Y,B]}),P=Object.prototype.hasOwnProperty,W=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,H=/[\x85\u2028\u2029]/,$=/[,\[\]\{\}]/,G=/^(?:!|!!|![a-z\-]+!)$/i,V=/^(?:!|[^,\[\]\{\}])(?:%[0-9a-f]{2}|[0-9a-z\-#;\/\?:@&=\+\$,_\.!~\*'\(\)\[\]])*$/i;function Z(e){return Object.prototype.toString.call(e)}function J(e){return 10===e||13===e}function Q(e){return 9===e||32===e}function z(e){return 9===e||32===e||10===e||13===e}function X(e){return 44===e||91===e||93===e||123===e||125===e}function ee(e){var t;return 48<=e&&e<=57?e-48:97<=(t=32|e)&&t<=102?t-97+10:-1}function te(e){return 48===e?"\0":97===e?"":98===e?"\b":116===e||9===e?"\t":110===e?"\n":118===e?"\v":102===e?"\f":114===e?"\r":101===e?"":32===e?" ":34===e?'"':47===e?"/":92===e?"\\":78===e?"…":95===e?" ":76===e?"\u2028":80===e?"\u2029":""}function ne(e){return e<=65535?String.fromCharCode(e):String.fromCharCode(55296+(e-65536>>10),56320+(e-65536&1023))}for(var ie=new Array(256),re=new Array(256),oe=0;oe<256;oe++)ie[oe]=te(oe)?1:0,re[oe]=te(oe);function ae(e,t){this.input=e,this.filename=t.filename||null,this.schema=t.schema||K,this.onWarning=t.onWarning||null,this.legacy=t.legacy||!1,this.json=t.json||!1,this.listener=t.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=e.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function le(e,t){var n={name:e.filename,buffer:e.input.slice(0,-1),position:e.position,line:e.line,column:e.position-e.lineStart};return n.snippet=c(n),new o(t,n)}function ce(e,t){throw le(e,t)}function se(e,t){e.onWarning&&e.onWarning.call(null,le(e,t))}var ue={YAML:function(e,t,n){var i,r,o;null!==e.version&&ce(e,"duplication of %YAML directive"),1!==n.length&&ce(e,"YAML directive accepts exactly one argument"),null===(i=/^([0-9]+)\.([0-9]+)$/.exec(n[0]))&&ce(e,"ill-formed argument of the YAML directive"),r=parseInt(i[1],10),o=parseInt(i[2],10),1!==r&&ce(e,"unacceptable YAML version of the document"),e.version=n[0],e.checkLineBreaks=o<2,1!==o&&2!==o&&se(e,"unsupported YAML version of the document")},TAG:function(e,t,n){var i,r;2!==n.length&&ce(e,"TAG directive accepts exactly two arguments"),i=n[0],r=n[1],G.test(i)||ce(e,"ill-formed tag handle (first argument) of the TAG directive"),P.call(e.tagMap,i)&&ce(e,'there is a previously declared suffix for "'+i+'" tag handle'),V.test(r)||ce(e,"ill-formed tag prefix (second argument) of the TAG directive");try{r=decodeURIComponent(r)}catch(t){ce(e,"tag prefix is malformed: "+r)}e.tagMap[i]=r}};function pe(e,t,n,i){var r,o,a,l;if(t<n){if(l=e.input.slice(t,n),i)for(r=0,o=l.length;r<o;r+=1)9===(a=l.charCodeAt(r))||32<=a&&a<=1114111||ce(e,"expected valid JSON character");else W.test(l)&&ce(e,"the stream contains non-printable characters");e.result+=l}}function fe(e,t,i,r){var o,a,l,c;for(n.isObject(i)||ce(e,"cannot merge mappings; the provided source object is unacceptable"),l=0,c=(o=Object.keys(i)).length;l<c;l+=1)a=o[l],P.call(t,a)||(t[a]=i[a],r[a]=!0)}function de(e,t,n,i,r,o,a,l,c){var s,u;if(Array.isArray(r))for(s=0,u=(r=Array.prototype.slice.call(r)).length;s<u;s+=1)Array.isArray(r[s])&&ce(e,"nested arrays are not supported inside keys"),"object"==typeof r&&"[object Object]"===Z(r[s])&&(r[s]="[object Object]");if("object"==typeof r&&"[object Object]"===Z(r)&&(r="[object Object]"),r=String(r),null===t&&(t={}),"tag:yaml.org,2002:merge"===i)if(Array.isArray(o))for(s=0,u=o.length;s<u;s+=1)fe(e,t,o[s],n);else fe(e,t,o,n);else e.json||P.call(n,r)||!P.call(t,r)||(e.line=a||e.line,e.lineStart=l||e.lineStart,e.position=c||e.position,ce(e,"duplicated mapping key")),"__proto__"===r?Object.defineProperty(t,r,{configurable:!0,enumerable:!0,writable:!0,value:o}):t[r]=o,delete n[r];return t}function he(e){var t;10===(t=e.input.charCodeAt(e.position))?e.position++:13===t?(e.position++,10===e.input.charCodeAt(e.position)&&e.position++):ce(e,"a line break is expected"),e.line+=1,e.lineStart=e.position,e.firstTabInLine=-1}function ge(e,t,n){for(var i=0,r=e.input.charCodeAt(e.position);0!==r;){for(;Q(r);)9===r&&-1===e.firstTabInLine&&(e.firstTabInLine=e.position),r=e.input.charCodeAt(++e.position);if(t&&35===r)do{r=e.input.charCodeAt(++e.position)}while(10!==r&&13!==r&&0!==r);if(!J(r))break;for(he(e),r=e.input.charCodeAt(e.position),i++,e.lineIndent=0;32===r;)e.lineIndent++,r=e.input.charCodeAt(++e.position)}return-1!==n&&0!==i&&e.lineIndent<n&&se(e,"deficient indentation"),i}function me(e){var t,n=e.position;return!(45!==(t=e.input.charCodeAt(n))&&46!==t||t!==e.input.charCodeAt(n+1)||t!==e.input.charCodeAt(n+2)||(n+=3,0!==(t=e.input.charCodeAt(n))&&!z(t)))}function ye(e,t){1===t?e.result+=" ":t>1&&(e.result+=n.repeat("\n",t-1))}function be(e,t){var n,i,r=e.tag,o=e.anchor,a=[],l=!1;if(-1!==e.firstTabInLine)return!1;for(null!==e.anchor&&(e.anchorMap[e.anchor]=a),i=e.input.charCodeAt(e.position);0!==i&&(-1!==e.firstTabInLine&&(e.position=e.firstTabInLine,ce(e,"tab characters must not be used in indentation")),45===i)&&z(e.input.charCodeAt(e.position+1));)if(l=!0,e.position++,ge(e,!0,-1)&&e.lineIndent<=t)a.push(null),i=e.input.charCodeAt(e.position);else if(n=e.line,we(e,t,3,!1,!0),a.push(e.result),ge(e,!0,-1),i=e.input.charCodeAt(e.position),(e.line===n||e.lineIndent>t)&&0!==i)ce(e,"bad indentation of a sequence entry");else if(e.lineIndent<t)break;return!!l&&(e.tag=r,e.anchor=o,e.kind="sequence",e.result=a,!0)}function Ae(e){var t,n,i,r,o=!1,a=!1;if(33!==(r=e.input.charCodeAt(e.position)))return!1;if(null!==e.tag&&ce(e,"duplication of a tag property"),60===(r=e.input.charCodeAt(++e.position))?(o=!0,r=e.input.charCodeAt(++e.position)):33===r?(a=!0,n="!!",r=e.input.charCodeAt(++e.position)):n="!",t=e.position,o){do{r=e.input.charCodeAt(++e.position)}while(0!==r&&62!==r);e.position<e.length?(i=e.input.slice(t,e.position),r=e.input.charCodeAt(++e.position)):ce(e,"unexpected end of the stream within a verbatim tag")}else{for(;0!==r&&!z(r);)33===r&&(a?ce(e,"tag suffix cannot contain exclamation marks"):(n=e.input.slice(t-1,e.position+1),G.test(n)||ce(e,"named tag handle cannot contain such characters"),a=!0,t=e.position+1)),r=e.input.charCodeAt(++e.position);i=e.input.slice(t,e.position),$.test(i)&&ce(e,"tag suffix cannot contain flow indicator characters")}i&&!V.test(i)&&ce(e,"tag name cannot contain such characters: "+i);try{i=decodeURIComponent(i)}catch(t){ce(e,"tag name is malformed: "+i)}return o?e.tag=i:P.call(e.tagMap,n)?e.tag=e.tagMap[n]+i:"!"===n?e.tag="!"+i:"!!"===n?e.tag="tag:yaml.org,2002:"+i:ce(e,'undeclared tag handle "'+n+'"'),!0}function ve(e){var t,n;if(38!==(n=e.input.charCodeAt(e.position)))return!1;for(null!==e.anchor&&ce(e,"duplication of an anchor property"),n=e.input.charCodeAt(++e.position),t=e.position;0!==n&&!z(n)&&!X(n);)n=e.input.charCodeAt(++e.position);return e.position===t&&ce(e,"name of an anchor node must contain at least one character"),e.anchor=e.input.slice(t,e.position),!0}function we(e,t,i,r,o){var a,l,c,s,u,p,f,d,h,g=1,m=!1,y=!1;if(null!==e.listener&&e.listener("open",e),e.tag=null,e.anchor=null,e.kind=null,e.result=null,a=l=c=4===i||3===i,r&&ge(e,!0,-1)&&(m=!0,e.lineIndent>t?g=1:e.lineIndent===t?g=0:e.lineIndent<t&&(g=-1)),1===g)for(;Ae(e)||ve(e);)ge(e,!0,-1)?(m=!0,c=a,e.lineIndent>t?g=1:e.lineIndent===t?g=0:e.lineIndent<t&&(g=-1)):c=!1;if(c&&(c=m||o),1!==g&&4!==i||(d=1===i||2===i?t:t+1,h=e.position-e.lineStart,1===g?c&&(be(e,h)||function(e,t,n){var i,r,o,a,l,c,s,u=e.tag,p=e.anchor,f={},d=Object.create(null),h=null,g=null,m=null,y=!1,b=!1;if(-1!==e.firstTabInLine)return!1;for(null!==e.anchor&&(e.anchorMap[e.anchor]=f),s=e.input.charCodeAt(e.position);0!==s;){if(y||-1===e.firstTabInLine||(e.position=e.firstTabInLine,ce(e,"tab characters must not be used in indentation")),i=e.input.charCodeAt(e.position+1),o=e.line,63!==s&&58!==s||!z(i)){if(a=e.line,l=e.lineStart,c=e.position,!we(e,n,2,!1,!0))break;if(e.line===o){for(s=e.input.charCodeAt(e.position);Q(s);)s=e.input.charCodeAt(++e.position);if(58===s)z(s=e.input.charCodeAt(++e.position))||ce(e,"a whitespace character is expected after the key-value separator within a block mapping"),y&&(de(e,f,d,h,g,null,a,l,c),h=g=m=null),b=!0,y=!1,r=!1,h=e.tag,g=e.result;else{if(!b)return e.tag=u,e.anchor=p,!0;ce(e,"can not read an implicit mapping pair; a colon is missed")}}else{if(!b)return e.tag=u,e.anchor=p,!0;ce(e,"can not read a block mapping entry; a multiline key may not be an implicit key")}}else 63===s?(y&&(de(e,f,d,h,g,null,a,l,c),h=g=m=null),b=!0,y=!0,r=!0):y?(y=!1,r=!0):ce(e,"incomplete explicit mapping pair; a key node is missed; or followed by a non-tabulated empty line"),e.position+=1,s=i;if((e.line===o||e.lineIndent>t)&&(y&&(a=e.line,l=e.lineStart,c=e.position),we(e,t,4,!0,r)&&(y?g=e.result:m=e.result),y||(de(e,f,d,h,g,m,a,l,c),h=g=m=null),ge(e,!0,-1),s=e.input.charCodeAt(e.position)),(e.line===o||e.lineIndent>t)&&0!==s)ce(e,"bad indentation of a mapping entry");else if(e.lineIndent<t)break}return y&&de(e,f,d,h,g,null,a,l,c),b&&(e.tag=u,e.anchor=p,e.kind="mapping",e.result=f),b}(e,h,d))||function(e,t){var n,i,r,o,a,l,c,s,u,p,f,d,h=!0,g=e.tag,m=e.anchor,y=Object.create(null);if(91===(d=e.input.charCodeAt(e.position)))a=93,s=!1,o=[];else{if(123!==d)return!1;a=125,s=!0,o={}}for(null!==e.anchor&&(e.anchorMap[e.anchor]=o),d=e.input.charCodeAt(++e.position);0!==d;){if(ge(e,!0,t),(d=e.input.charCodeAt(e.position))===a)return e.position++,e.tag=g,e.anchor=m,e.kind=s?"mapping":"sequence",e.result=o,!0;h?44===d&&ce(e,"expected the node content, but found ','"):ce(e,"missed comma between flow collection entries"),f=null,l=c=!1,63===d&&z(e.input.charCodeAt(e.position+1))&&(l=c=!0,e.position++,ge(e,!0,t)),n=e.line,i=e.lineStart,r=e.position,we(e,t,1,!1,!0),p=e.tag,u=e.result,ge(e,!0,t),d=e.input.charCodeAt(e.position),!c&&e.line!==n||58!==d||(l=!0,d=e.input.charCodeAt(++e.position),ge(e,!0,t),we(e,t,1,!1,!0),f=e.result),s?de(e,o,y,p,u,f,n,i,r):l?o.push(de(e,null,y,p,u,f,n,i,r)):o.push(u),ge(e,!0,t),44===(d=e.input.charCodeAt(e.position))?(h=!0,d=e.input.charCodeAt(++e.position)):h=!1}ce(e,"unexpected end of the stream within a flow collection")}(e,d)?y=!0:(l&&function(e,t){var i,r,o,a,l,c=1,s=!1,u=!1,p=t,f=0,d=!1;if(124===(a=e.input.charCodeAt(e.position)))r=!1;else{if(62!==a)return!1;r=!0}for(e.kind="scalar",e.result="";0!==a;)if(43===(a=e.input.charCodeAt(++e.position))||45===a)1===c?c=43===a?3:2:ce(e,"repeat of a chomping mode identifier");else{if(!((o=48<=(l=a)&&l<=57?l-48:-1)>=0))break;0===o?ce(e,"bad explicit indentation width of a block scalar; it cannot be less than one"):u?ce(e,"repeat of an indentation width identifier"):(p=t+o-1,u=!0)}if(Q(a)){do{a=e.input.charCodeAt(++e.position)}while(Q(a));if(35===a)do{a=e.input.charCodeAt(++e.position)}while(!J(a)&&0!==a)}for(;0!==a;){for(he(e),e.lineIndent=0,a=e.input.charCodeAt(e.position);(!u||e.lineIndent<p)&&32===a;)e.lineIndent++,a=e.input.charCodeAt(++e.position);if(!u&&e.lineIndent>p&&(p=e.lineIndent),J(a))f++;else{if(e.lineIndent<p){3===c?e.result+=n.repeat("\n",s?1+f:f):1===c&&s&&(e.result+="\n");break}for(r?Q(a)?(d=!0,e.result+=n.repeat("\n",s?1+f:f)):d?(d=!1,e.result+=n.repeat("\n",f+1)):0===f?s&&(e.result+=" "):e.result+=n.repeat("\n",f):e.result+=n.repeat("\n",s?1+f:f),s=!0,u=!0,f=0,i=e.position;!J(a)&&0!==a;)a=e.input.charCodeAt(++e.position);pe(e,i,e.position,!1)}}return!0}(e,d)||function(e,t){var n,i,r;if(39!==(n=e.input.charCodeAt(e.position)))return!1;for(e.kind="scalar",e.result="",e.position++,i=r=e.position;0!==(n=e.input.charCodeAt(e.position));)if(39===n){if(pe(e,i,e.position,!0),39!==(n=e.input.charCodeAt(++e.position)))return!0;i=e.position,e.position++,r=e.position}else J(n)?(pe(e,i,r,!0),ye(e,ge(e,!1,t)),i=r=e.position):e.position===e.lineStart&&me(e)?ce(e,"unexpected end of the document within a single quoted scalar"):(e.position++,r=e.position);ce(e,"unexpected end of the stream within a single quoted scalar")}(e,d)||function(e,t){var n,i,r,o,a,l,c;if(34!==(l=e.input.charCodeAt(e.position)))return!1;for(e.kind="scalar",e.result="",e.position++,n=i=e.position;0!==(l=e.input.charCodeAt(e.position));){if(34===l)return pe(e,n,e.position,!0),e.position++,!0;if(92===l){if(pe(e,n,e.position,!0),J(l=e.input.charCodeAt(++e.position)))ge(e,!1,t);else if(l<256&&ie[l])e.result+=re[l],e.position++;else if((a=120===(c=l)?2:117===c?4:85===c?8:0)>0){for(r=a,o=0;r>0;r--)(a=ee(l=e.input.charCodeAt(++e.position)))>=0?o=(o<<4)+a:ce(e,"expected hexadecimal character");e.result+=ne(o),e.position++}else ce(e,"unknown escape sequence");n=i=e.position}else J(l)?(pe(e,n,i,!0),ye(e,ge(e,!1,t)),n=i=e.position):e.position===e.lineStart&&me(e)?ce(e,"unexpected end of the document within a double quoted scalar"):(e.position++,i=e.position)}ce(e,"unexpected end of the stream within a double quoted scalar")}(e,d)?y=!0:!function(e){var t,n,i;if(42!==(i=e.input.charCodeAt(e.position)))return!1;for(i=e.input.charCodeAt(++e.position),t=e.position;0!==i&&!z(i)&&!X(i);)i=e.input.charCodeAt(++e.position);return e.position===t&&ce(e,"name of an alias node must contain at least one character"),n=e.input.slice(t,e.position),P.call(e.anchorMap,n)||ce(e,'unidentified alias "'+n+'"'),e.result=e.anchorMap[n],ge(e,!0,-1),!0}(e)?function(e,t,n){var i,r,o,a,l,c,s,u,p=e.kind,f=e.result;if(z(u=e.input.charCodeAt(e.position))||X(u)||35===u||38===u||42===u||33===u||124===u||62===u||39===u||34===u||37===u||64===u||96===u)return!1;if((63===u||45===u)&&(z(i=e.input.charCodeAt(e.position+1))||n&&X(i)))return!1;for(e.kind="scalar",e.result="",r=o=e.position,a=!1;0!==u;){if(58===u){if(z(i=e.input.charCodeAt(e.position+1))||n&&X(i))break}else if(35===u){if(z(e.input.charCodeAt(e.position-1)))break}else{if(e.position===e.lineStart&&me(e)||n&&X(u))break;if(J(u)){if(l=e.line,c=e.lineStart,s=e.lineIndent,ge(e,!1,-1),e.lineIndent>=t){a=!0,u=e.input.charCodeAt(e.position);continue}e.position=o,e.line=l,e.lineStart=c,e.lineIndent=s;break}}a&&(pe(e,r,o,!1),ye(e,e.line-l),r=o=e.position,a=!1),Q(u)||(o=e.position+1),u=e.input.charCodeAt(++e.position)}return pe(e,r,o,!1),!!e.result||(e.kind=p,e.result=f,!1)}(e,d,1===i)&&(y=!0,null===e.tag&&(e.tag="?")):(y=!0,null===e.tag&&null===e.anchor||ce(e,"alias node should not have any properties")),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):0===g&&(y=c&&be(e,h))),null===e.tag)null!==e.anchor&&(e.anchorMap[e.anchor]=e.result);else if("?"===e.tag){for(null!==e.result&&"scalar"!==e.kind&&ce(e,'unacceptable node kind for !<?> tag; it should be "scalar", not "'+e.kind+'"'),s=0,u=e.implicitTypes.length;s<u;s+=1)if((f=e.implicitTypes[s]).resolve(e.result)){e.result=f.construct(e.result),e.tag=f.tag,null!==e.anchor&&(e.anchorMap[e.anchor]=e.result);break}}else if("!"!==e.tag){if(P.call(e.typeMap[e.kind||"fallback"],e.tag))f=e.typeMap[e.kind||"fallback"][e.tag];else for(f=null,s=0,u=(p=e.typeMap.multi[e.kind||"fallback"]).length;s<u;s+=1)if(e.tag.slice(0,p[s].tag.length)===p[s].tag){f=p[s];break}f||ce(e,"unknown tag !<"+e.tag+">"),null!==e.result&&f.kind!==e.kind&&ce(e,"unacceptable node kind for !<"+e.tag+'> tag; it should be "'+f.kind+'", not "'+e.kind+'"'),f.resolve(e.result,e.tag)?(e.result=f.construct(e.result,e.tag),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):ce(e,"cannot resolve a node with !<"+e.tag+"> explicit tag")}return null!==e.listener&&e.listener("close",e),null!==e.tag||null!==e.anchor||y}function ke(e){var t,n,i,r,o=e.position,a=!1;for(e.version=null,e.checkLineBreaks=e.legacy,e.tagMap=Object.create(null),e.anchorMap=Object.create(null);0!==(r=e.input.charCodeAt(e.position))&&(ge(e,!0,-1),r=e.input.charCodeAt(e.position),!(e.lineIndent>0||37!==r));){for(a=!0,r=e.input.charCodeAt(++e.position),t=e.position;0!==r&&!z(r);)r=e.input.charCodeAt(++e.position);for(i=[],(n=e.input.slice(t,e.position)).length<1&&ce(e,"directive name must not be less than one character in length");0!==r;){for(;Q(r);)r=e.input.charCodeAt(++e.position);if(35===r){do{r=e.input.charCodeAt(++e.position)}while(0!==r&&!J(r));break}if(J(r))break;for(t=e.position;0!==r&&!z(r);)r=e.input.charCodeAt(++e.position);i.push(e.input.slice(t,e.position))}0!==r&&he(e),P.call(ue,n)?ue[n](e,n,i):se(e,'unknown document directive "'+n+'"')}ge(e,!0,-1),0===e.lineIndent&&45===e.input.charCodeAt(e.position)&&45===e.input.charCodeAt(e.position+1)&&45===e.input.charCodeAt(e.position+2)?(e.position+=3,ge(e,!0,-1)):a&&ce(e,"directives end mark is expected"),we(e,e.lineIndent-1,4,!1,!0),ge(e,!0,-1),e.checkLineBreaks&&H.test(e.input.slice(o,e.position))&&se(e,"non-ASCII line breaks are interpreted as content"),e.documents.push(e.result),e.position===e.lineStart&&me(e)?46===e.input.charCodeAt(e.position)&&(e.position+=3,ge(e,!0,-1)):e.position<e.length-1&&ce(e,"end of the stream or a document separator is expected")}function Ce(e,t){t=t||{},0!==(e=String(e)).length&&(10!==e.charCodeAt(e.length-1)&&13!==e.charCodeAt(e.length-1)&&(e+="\n"),65279===e.charCodeAt(0)&&(e=e.slice(1)));var n=new ae(e,t),i=e.indexOf("\0");for(-1!==i&&(n.position=i,ce(n,"null byte is not allowed in input")),n.input+="\0";32===n.input.charCodeAt(n.position);)n.lineIndent+=1,n.position+=1;for(;n.position<n.length-1;)ke(n);return n.documents}var xe={loadAll:function(e,t,n){null!==t&&"object"==typeof t&&void 0===n&&(n=t,t=null);var i=Ce(e,n);if("function"!=typeof t)return i;for(var r=0,o=i.length;r<o;r+=1)t(i[r])},load:function(e,t){var n=Ce(e,t);if(0!==n.length){if(1===n.length)return n[0];throw new o("expected a single document in the stream, but found more")}}},Ie=Object.prototype.toString,Se=Object.prototype.hasOwnProperty,Oe=65279,je={0:"\\0",7:"\\a",8:"\\b",9:"\\t",10:"\\n",11:"\\v",12:"\\f",13:"\\r",27:"\\e",34:'\\"',92:"\\\\",133:"\\N",160:"\\_",8232:"\\L",8233:"\\P"},Te=["y","Y","yes","Yes","YES","on","On","ON","n","N","no","No","NO","off","Off","OFF"],Ne=/^[-+]?[0-9_]+(?::[0-9_]+)+(?:\.[0-9_]*)?$/;function Fe(e){var t,i,r;if(t=e.toString(16).toUpperCase(),e<=255)i="x",r=2;else if(e<=65535)i="u",r=4;else{if(!(e<=4294967295))throw new o("code point within a string may not be greater than 0xFFFFFFFF");i="U",r=8}return"\\"+i+n.repeat("0",r-t.length)+t}function Ee(e){this.schema=e.schema||K,this.indent=Math.max(1,e.indent||2),this.noArrayIndent=e.noArrayIndent||!1,this.skipInvalid=e.skipInvalid||!1,this.flowLevel=n.isNothing(e.flowLevel)?-1:e.flowLevel,this.styleMap=function(e,t){var n,i,r,o,a,l,c;if(null===t)return{};for(n={},r=0,o=(i=Object.keys(t)).length;r<o;r+=1)a=i[r],l=String(t[a]),"!!"===a.slice(0,2)&&(a="tag:yaml.org,2002:"+a.slice(2)),(c=e.compiledTypeMap.fallback[a])&&Se.call(c.styleAliases,l)&&(l=c.styleAliases[l]),n[a]=l;return n}(this.schema,e.styles||null),this.sortKeys=e.sortKeys||!1,this.lineWidth=e.lineWidth||80,this.noRefs=e.noRefs||!1,this.noCompatMode=e.noCompatMode||!1,this.condenseFlow=e.condenseFlow||!1,this.quotingType='"'===e.quotingType?2:1,this.forceQuotes=e.forceQuotes||!1,this.replacer="function"==typeof e.replacer?e.replacer:null,this.implicitTypes=this.schema.compiledImplicit,this.explicitTypes=this.schema.compiledExplicit,this.tag=null,this.result="",this.duplicates=[],this.usedDuplicates=null}function Me(e,t){for(var i,r=n.repeat(" ",t),o=0,a=-1,l="",c=e.length;o<c;)-1===(a=e.indexOf("\n",o))?(i=e.slice(o),o=c):(i=e.slice(o,a+1),o=a+1),i.length&&"\n"!==i&&(l+=r),l+=i;return l}function Le(e,t){return"\n"+n.repeat(" ",e.indent*t)}function _e(e){return 32===e||9===e}function De(e){return 32<=e&&e<=126||161<=e&&e<=55295&&8232!==e&&8233!==e||57344<=e&&e<=65533&&e!==Oe||65536<=e&&e<=1114111}function Ue(e){return De(e)&&e!==Oe&&13!==e&&10!==e}function qe(e,t,n){var i=Ue(e),r=i&&!_e(e);return(n?i:i&&44!==e&&91!==e&&93!==e&&123!==e&&125!==e)&&35!==e&&!(58===t&&!r)||Ue(t)&&!_e(t)&&35===e||58===t&&r}function Ye(e,t){var n,i=e.charCodeAt(t);return i>=55296&&i<=56319&&t+1<e.length&&(n=e.charCodeAt(t+1))>=56320&&n<=57343?1024*(i-55296)+n-56320+65536:i}function Re(e){return/^\n* /.test(e)}function Be(e,t,n,i,r,o,a,l){var c,s,u=0,p=null,f=!1,d=!1,h=-1!==i,g=-1,m=De(s=Ye(e,0))&&s!==Oe&&!_e(s)&&45!==s&&63!==s&&58!==s&&44!==s&&91!==s&&93!==s&&123!==s&&125!==s&&35!==s&&38!==s&&42!==s&&33!==s&&124!==s&&61!==s&&62!==s&&39!==s&&34!==s&&37!==s&&64!==s&&96!==s&&function(e){return!_e(e)&&58!==e}(Ye(e,e.length-1));if(t||a)for(c=0;c<e.length;u>=65536?c+=2:c++){if(!De(u=Ye(e,c)))return 5;m=m&&qe(u,p,l),p=u}else{for(c=0;c<e.length;u>=65536?c+=2:c++){if(10===(u=Ye(e,c)))f=!0,h&&(d=d||c-g-1>i&&" "!==e[g+1],g=c);else if(!De(u))return 5;m=m&&qe(u,p,l),p=u}d=d||h&&c-g-1>i&&" "!==e[g+1]}return f||d?n>9&&Re(e)?5:a?2===o?5:2:d?4:3:!m||a||r(e)?2===o?5:2:1}function Ke(e,t,n,i,r){e.dump=function(){if(0===t.length)return 2===e.quotingType?'""':"''";if(!e.noCompatMode&&(-1!==Te.indexOf(t)||Ne.test(t)))return 2===e.quotingType?'"'+t+'"':"'"+t+"'";var a=e.indent*Math.max(1,n),l=-1===e.lineWidth?-1:Math.max(Math.min(e.lineWidth,40),e.lineWidth-a),c=i||e.flowLevel>-1&&n>=e.flowLevel;switch(Be(t,c,e.indent,l,(function(t){return function(e,t){var n,i;for(n=0,i=e.implicitTypes.length;n<i;n+=1)if(e.implicitTypes[n].resolve(t))return!0;return!1}(e,t)}),e.quotingType,e.forceQuotes&&!i,r)){case 1:return t;case 2:return"'"+t.replace(/'/g,"''")+"'";case 3:return"|"+Pe(t,e.indent)+We(Me(t,a));case 4:return">"+Pe(t,e.indent)+We(Me(function(e,t){var n,i,r=/(\n+)([^\n]*)/g,o=(l=e.indexOf("\n"),l=-1!==l?l:e.length,r.lastIndex=l,He(e.slice(0,l),t)),a="\n"===e[0]||" "===e[0];var l;for(;i=r.exec(e);){var c=i[1],s=i[2];n=" "===s[0],o+=c+(a||n||""===s?"":"\n")+He(s,t),a=n}return o}(t,l),a));case 5:return'"'+function(e){for(var t,n="",i=0,r=0;r<e.length;i>=65536?r+=2:r++)i=Ye(e,r),!(t=je[i])&&De(i)?(n+=e[r],i>=65536&&(n+=e[r+1])):n+=t||Fe(i);return n}(t)+'"';default:throw new o("impossible error: invalid scalar style")}}()}function Pe(e,t){var n=Re(e)?String(t):"",i="\n"===e[e.length-1];return n+(i&&("\n"===e[e.length-2]||"\n"===e)?"+":i?"":"-")+"\n"}function We(e){return"\n"===e[e.length-1]?e.slice(0,-1):e}function He(e,t){if(""===e||" "===e[0])return e;for(var n,i,r=/ [^ ]/g,o=0,a=0,l=0,c="";n=r.exec(e);)(l=n.index)-o>t&&(i=a>o?a:l,c+="\n"+e.slice(o,i),o=i+1),a=l;return c+="\n",e.length-o>t&&a>o?c+=e.slice(o,a)+"\n"+e.slice(a+1):c+=e.slice(o),c.slice(1)}function $e(e,t,n,i){var r,o,a,l="",c=e.tag;for(r=0,o=n.length;r<o;r+=1)a=n[r],e.replacer&&(a=e.replacer.call(n,String(r),a)),(Ve(e,t+1,a,!0,!0,!1,!0)||void 0===a&&Ve(e,t+1,null,!0,!0,!1,!0))&&(i&&""===l||(l+=Le(e,t)),e.dump&&10===e.dump.charCodeAt(0)?l+="-":l+="- ",l+=e.dump);e.tag=c,e.dump=l||"[]"}function Ge(e,t,n){var i,r,a,l,c,s;for(a=0,l=(r=n?e.explicitTypes:e.implicitTypes).length;a<l;a+=1)if(((c=r[a]).instanceOf||c.predicate)&&(!c.instanceOf||"object"==typeof t&&t instanceof c.instanceOf)&&(!c.predicate||c.predicate(t))){if(n?c.multi&&c.representName?e.tag=c.representName(t):e.tag=c.tag:e.tag="?",c.represent){if(s=e.styleMap[c.tag]||c.defaultStyle,"[object Function]"===Ie.call(c.represent))i=c.represent(t,s);else{if(!Se.call(c.represent,s))throw new o("!<"+c.tag+'> tag resolver accepts not "'+s+'" style');i=c.represent[s](t,s)}e.dump=i}return!0}return!1}function Ve(e,t,n,i,r,a,l){e.tag=null,e.dump=n,Ge(e,n,!1)||Ge(e,n,!0);var c,s=Ie.call(e.dump),u=i;i&&(i=e.flowLevel<0||e.flowLevel>t);var p,f,d="[object Object]"===s||"[object Array]"===s;if(d&&(f=-1!==(p=e.duplicates.indexOf(n))),(null!==e.tag&&"?"!==e.tag||f||2!==e.indent&&t>0)&&(r=!1),f&&e.usedDuplicates[p])e.dump="*ref_"+p;else{if(d&&f&&!e.usedDuplicates[p]&&(e.usedDuplicates[p]=!0),"[object Object]"===s)i&&0!==Object.keys(e.dump).length?(!function(e,t,n,i){var r,a,l,c,s,u,p="",f=e.tag,d=Object.keys(n);if(!0===e.sortKeys)d.sort();else if("function"==typeof e.sortKeys)d.sort(e.sortKeys);else if(e.sortKeys)throw new o("sortKeys must be a boolean or a function");for(r=0,a=d.length;r<a;r+=1)u="",i&&""===p||(u+=Le(e,t)),c=n[l=d[r]],e.replacer&&(c=e.replacer.call(n,l,c)),Ve(e,t+1,l,!0,!0,!0)&&((s=null!==e.tag&&"?"!==e.tag||e.dump&&e.dump.length>1024)&&(e.dump&&10===e.dump.charCodeAt(0)?u+="?":u+="? "),u+=e.dump,s&&(u+=Le(e,t)),Ve(e,t+1,c,!0,s)&&(e.dump&&10===e.dump.charCodeAt(0)?u+=":":u+=": ",p+=u+=e.dump));e.tag=f,e.dump=p||"{}"}(e,t,e.dump,r),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var i,r,o,a,l,c="",s=e.tag,u=Object.keys(n);for(i=0,r=u.length;i<r;i+=1)l="",""!==c&&(l+=", "),e.condenseFlow&&(l+='"'),a=n[o=u[i]],e.replacer&&(a=e.replacer.call(n,o,a)),Ve(e,t,o,!1,!1)&&(e.dump.length>1024&&(l+="? "),l+=e.dump+(e.condenseFlow?'"':"")+":"+(e.condenseFlow?"":" "),Ve(e,t,a,!1,!1)&&(c+=l+=e.dump));e.tag=s,e.dump="{"+c+"}"}(e,t,e.dump),f&&(e.dump="&ref_"+p+" "+e.dump));else if("[object Array]"===s)i&&0!==e.dump.length?(e.noArrayIndent&&!l&&t>0?$e(e,t-1,e.dump,r):$e(e,t,e.dump,r),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var i,r,o,a="",l=e.tag;for(i=0,r=n.length;i<r;i+=1)o=n[i],e.replacer&&(o=e.replacer.call(n,String(i),o)),(Ve(e,t,o,!1,!1)||void 0===o&&Ve(e,t,null,!1,!1))&&(""!==a&&(a+=","+(e.condenseFlow?"":" ")),a+=e.dump);e.tag=l,e.dump="["+a+"]"}(e,t,e.dump),f&&(e.dump="&ref_"+p+" "+e.dump));else{if("[object String]"!==s){if("[object Undefined]"===s)return!1;if(e.skipInvalid)return!1;throw new o("unacceptable kind of an object to dump "+s)}"?"!==e.tag&&Ke(e,e.dump,t,a,u)}null!==e.tag&&"?"!==e.tag&&(c=encodeURI("!"===e.tag[0]?e.tag.slice(1):e.tag).replace(/!/g,"%21"),c="!"===e.tag[0]?"!"+c:"tag:yaml.org,2002:"===c.slice(0,18)?"!!"+c.slice(18):"!<"+c+">",e.dump=c+" "+e.dump)}return!0}function Ze(e,t){var n,i,r=[],o=[];for(Je(e,r,o),n=0,i=o.length;n<i;n+=1)t.duplicates.push(r[o[n]]);t.usedDuplicates=new Array(i)}function Je(e,t,n){var i,r,o;if(null!==e&&"object"==typeof e)if(-1!==(r=t.indexOf(e)))-1===n.indexOf(r)&&n.push(r);else if(t.push(e),Array.isArray(e))for(r=0,o=e.length;r<o;r+=1)Je(e[r],t,n);else for(r=0,o=(i=Object.keys(e)).length;r<o;r+=1)Je(e[i[r]],t,n)}function Qe(e,t){return function(){throw new Error("Function yaml."+e+" is removed in js-yaml 4. Use yaml."+t+" instead, which is now safe by default.")}}var ze=p,Xe=h,et=b,tt=O,nt=j,it=K,rt=xe.load,ot=xe.loadAll,at={dump:function(e,t){var n=new Ee(t=t||{});n.noRefs||Ze(e,n);var i=e;return n.replacer&&(i=n.replacer.call({"":i},"",i)),Ve(n,0,i,!0,!0)?n.dump+"\n":""}}.dump,lt=o,ct={binary:L,float:S,map:y,null:A,pairs:Y,set:B,timestamp:F,bool:v,int:C,merge:E,omap:U,seq:m,str:g},st=Qe("safeLoad","load"),ut=Qe("safeLoadAll","loadAll"),pt=Qe("safeDump","dump"),ft={Type:ze,Schema:Xe,FAILSAFE_SCHEMA:et,JSON_SCHEMA:tt,CORE_SCHEMA:nt,DEFAULT_SCHEMA:it,load:rt,loadAll:ot,dump:at,YAMLException:lt,types:ct,safeLoad:st,safeLoadAll:ut,safeDump:pt};e.CORE_SCHEMA=nt,e.DEFAULT_SCHEMA=it,e.FAILSAFE_SCHEMA=et,e.JSON_SCHEMA=tt,e.Schema=Xe,e.Type=ze,e.YAMLException=lt,e.default=ft,e.dump=at,e.load=rt,e.loadAll=ot,e.safeDump=pt,e.safeLoad=st,e.safeLoadAll=ut,e.types=ct,Object.defineProperty(e,"__esModule",{value:!0})}));
/**
* ZDDC — shared naming convention library
*
* Canonical implementation of all ZDDC filename, folder name, tracking number,
* revision, and status logic. Included in every tool's build via shared/zddc.js.
*
* Exposed as window.zddc (plain global) so it works with every tool's module
* pattern (archive globals, classifier IIFE, transmittal IIFE, mdedit globals).
*
* Public API
* ----------
* zddc.parseFilename(str) → ParsedFile | null
* zddc.parseFolder(str) → ParsedFolder | null
* zddc.parseRevision(str) → ParsedRevision
* zddc.formatFilename(parts) → string
* zddc.formatFolder(parts) → string
* zddc.compareRevisions(a, b) → number (-1 | 0 | 1)
* zddc.isValidStatus(str) → boolean
* zddc.STATUSES → string[]
*
* ParsedFile { trackingNumber, revision, status, title, extension }
* ParsedFolder { date, trackingNumber, status, title }
* ParsedRevision { base, modifier, modifierType, modifierNumber, isDraft, full }
*/
(function (root) {
'use strict';
// ── Valid status codes ───────────────────────────────────────────────────
/**
* Complete list of valid ZDDC document status codes.
* '---' denotes an unknown or not-yet-assigned status.
*/
var STATUSES = [
'---',
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
'REC',
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
];
var STATUS_SET = {};
for (var _i = 0; _i < STATUSES.length; _i++) {
STATUS_SET[STATUSES[_i]] = true;
}
function isValidStatus(str) {
return !!STATUS_SET[str];
}
// ── Filename parsing ─────────────────────────────────────────────────────
/**
* Canonical file regex.
* Matches: TRACKING_REVISION (STATUS) - TITLE.EXT
*
* Tracking number: no underscores, no whitespace.
* Revision: no whitespace, no parentheses.
* Status: anything inside parentheses (validated separately).
* Title: everything up to the last dot.
* Extension: after the last dot (lowercased by parseFilename).
*/
var FILE_RE = /^([^_\s]+)_([^\s()_]+)\s*\(([^)]+)\)\s*-\s*(\S.*\S|\S)\.\s*([^\s.]+)$/;
/**
* Parse a ZDDC filename.
*
* @param {string} filename
* @returns {{ trackingNumber: string, revision: string, status: string,
* title: string, extension: string, valid: boolean } | null}
* null only if filename is falsy.
* `valid` is true when all fields matched the ZDDC pattern.
*/
function parseFilename(filename) {
if (!filename) { return null; }
var match = filename.match(FILE_RE);
if (!match) {
var lastDot = filename.lastIndexOf('.');
return {
trackingNumber: '',
revision: '',
status: '',
title: lastDot > 0 ? filename.substring(0, lastDot) : filename,
extension: lastDot > 0 ? filename.substring(lastDot + 1).toLowerCase() : '',
valid: false,
};
}
return {
trackingNumber: match[1].trim(),
revision: match[2].trim(),
status: match[3].trim(),
title: match[4].trim(),
extension: match[5].toLowerCase(),
valid: true,
};
}
// ── Folder name parsing ──────────────────────────────────────────────────
/**
* Transmittal folder regex.
* Matches: YYYY-MM-DD_TRACKING (STATUS) - TITLE
*/
var FOLDER_RE = /^(\d{4}-\d{2}-\d{2})_([^_\s(]+)\s*\(([^)]+)\)\s*-\s*(.+)$/;
/**
* Parse a ZDDC transmittal folder name.
*
* @param {string} foldername
* @returns {{ date: string, trackingNumber: string, status: string,
* title: string, valid: boolean } | null}
* null only if foldername is falsy.
*/
function parseFolder(foldername) {
if (!foldername) { return null; }
var match = foldername.match(FOLDER_RE);
if (!match) {
return {
date: '',
trackingNumber: '',
status: '',
title: foldername,
valid: false,
};
}
return {
date: match[1],
trackingNumber: match[2].trim(),
status: match[3].trim(),
title: match[4].trim(),
valid: true,
};
}
// ── Revision parsing ─────────────────────────────────────────────────────
/**
* Modifier sub-regex: +LETTER DIGITS e.g. +C1, +B2, +N1, +Q1
* The draft prefix (~) may appear inside the modifier: A+~C1
*/
var MODIFIER_RE = /^\+(~?)([A-Za-z])(\d+)$/;
/**
* Parse a ZDDC revision string.
*
* Revision grammar:
* revision = ['~'] base ['+' ['~'] modifier_letter modifier_number]
* base = letter(s) | digit(s) | date(YYYY-MM-DD)
* modifier = letter + digits e.g. C1, B2, N1, Q1
*
* @param {string} revision
* @returns {{
* base: string,
* modifier: string, full modifier string e.g. '+C1', '' if none
* modifierType: string, modifier letter e.g. 'C', '' if none
* modifierNumber: number, modifier number e.g. 1, 0 if none
* modifierIsDraft: boolean,
* isDraft: boolean, true if base revision starts with ~
* full: string, original input
* }}
*/
function parseRevision(revision) {
var raw = (revision || '').toString();
// Split on '+' to separate base from optional modifier
var plusIdx = raw.indexOf('+');
var basePart = plusIdx === -1 ? raw : raw.substring(0, plusIdx);
var modifierPart = plusIdx === -1 ? '' : raw.substring(plusIdx);
// Draft flag on the base part
var isDraft = basePart.startsWith('~');
var base = isDraft ? basePart.substring(1) : basePart;
// Parse modifier
var modifier = '';
var modifierType = '';
var modifierNumber = 0;
var modifierIsDraft = false;
if (modifierPart) {
var mMatch = modifierPart.match(MODIFIER_RE);
if (mMatch) {
modifierIsDraft = mMatch[1] === '~';
modifierType = mMatch[2].toUpperCase();
modifierNumber = parseInt(mMatch[3], 10);
modifier = modifierPart;
} else {
// Unrecognised modifier — preserve as-is
modifier = modifierPart;
}
}
return {
base: base,
modifier: modifier,
modifierType: modifierType,
modifierNumber: modifierNumber,
modifierIsDraft: modifierIsDraft,
isDraft: isDraft,
full: raw,
};
}
// ── Revision comparison ──────────────────────────────────────────────────
/**
* Classify a base revision string into a sort tier:
* 0 = date (YYYY-MM-DD)
* 1 = letter(s) A, B, AA …
* 2 = number(s) 0, 1, 2, 1.5 …
* 3 = other
*/
function _baseTier(base) {
if (/^\d{4}-\d{2}-\d{2}$/.test(base)) { return 0; }
if (/^[A-Za-z]+$/.test(base)) { return 1; }
if (/^\d+(\.\d+)?$/.test(base)) { return 2; }
return 3;
}
/**
* Compare two base revision strings.
* Sort order: dates < letters < numbers < other.
*/
function _compareBase(a, b) {
var ta = _baseTier(a);
var tb = _baseTier(b);
if (ta !== tb) { return ta - tb; }
if (ta === 0) { return a < b ? -1 : a > b ? 1 : 0; } // date lexicographic = chronological
if (ta === 1) { return a.toUpperCase() < b.toUpperCase() ? -1 : a.toUpperCase() > b.toUpperCase() ? 1 : 0; }
if (ta === 2) { return parseFloat(a) - parseFloat(b); }
return a.localeCompare(b);
}
/**
* Compare two ZDDC revision strings for sort ordering.
*
* Canonical order (ascending = older → newer):
* ~A < A < A+B1 < A+C1 < A+~C2 < A+C2 < A+N1 < A+Q1
* < ~B < B < … < 0 < 1 < 2
*
* Rules:
* 1. Compare base revisions first (dates < letters < numbers).
* 2. For equal bases, draft (isDraft=true) comes before final.
* 3. For equal base+draft, no-modifier < has-modifier.
* 4. For equal base+draft+modifier presence:
* a. modifier draft comes before modifier final (modifierIsDraft).
* b. Sort modifier by type letter then by number.
*
* @param {string} a
* @param {string} b
* @returns {number} negative if a < b, 0 if equal, positive if a > b
*/
function compareRevisions(a, b) {
var pa = parseRevision(a);
var pb = parseRevision(b);
// 1. Base revision
var baseCmp = _compareBase(pa.base, pb.base);
if (baseCmp !== 0) { return baseCmp; }
// 2. Draft before final (for same base)
if (pa.isDraft !== pb.isDraft) { return pa.isDraft ? -1 : 1; }
// 3. No modifier before any modifier
var aHasMod = pa.modifier !== '';
var bHasMod = pb.modifier !== '';
if (aHasMod !== bHasMod) { return aHasMod ? 1 : -1; }
if (!aHasMod) { return 0; } // both have no modifier
// 4. Compare modifiers: type → number → draft (draft is a tie-breaker only)
// 4a. Modifier type letter (B < C < N < Q …)
if (pa.modifierType !== pb.modifierType) {
return pa.modifierType < pb.modifierType ? -1 : 1;
}
// 4b. Modifier number (1 < 2 …)
if (pa.modifierNumber !== pb.modifierNumber) {
return pa.modifierNumber - pb.modifierNumber;
}
// 4c. Draft of a modifier comes before the final modifier (same type+number)
if (pa.modifierIsDraft !== pb.modifierIsDraft) {
return pa.modifierIsDraft ? -1 : 1;
}
return 0;
}
// ── Filename / folder formatting ─────────────────────────────────────────
/**
* Build a ZDDC filename from its components.
*
* @param {{ trackingNumber: string, revision: string, status: string,
* title: string, extension: string }} parts
* @returns {string} e.g. "123456-EL-SPC-2623_A (IFR) - Specification.pdf"
*/
function formatFilename(parts) {
var tn = (parts.trackingNumber || '').trim();
var rev = (parts.revision || '').trim();
var st = (parts.status || '').trim();
var ttl = (parts.title || '').trim();
var ext = (parts.extension || '').replace(/^\./, '');
if (!tn || !rev || !st || !ttl) { return ''; }
var name = tn + '_' + rev + ' (' + st + ') - ' + ttl;
return ext ? name + '.' + ext : name;
}
/**
* Build a ZDDC transmittal folder name from its components.
*
* @param {{ date: string, trackingNumber: string, status: string,
* title: string }} parts
* @returns {string} e.g. "2025-10-31_123456-EM-SUB-0001 (IFR) - Title"
*/
function formatFolder(parts) {
var dt = (parts.date || '').trim();
var tn = (parts.trackingNumber || '').trim();
var st = (parts.status || '').trim();
var ttl = (parts.title || '').trim();
if (!dt || !tn || !st || !ttl) { return ''; }
return dt + '_' + tn + ' (' + st + ') - ' + ttl;
}
// ── Filename / extension splitting ───────────────────────────────────────
/**
* Split a filename into its base name and extension (no leading dot).
* Treats leading dot ('.gitignore') as no extension.
*
* @param {string} filename
* @returns {{ name: string, extension: string }}
*/
function splitExtension(filename) {
if (!filename) { return { name: '', extension: '' }; }
var lastDot = filename.lastIndexOf('.');
if (lastDot <= 0) { return { name: filename, extension: '' }; }
return {
name: filename.substring(0, lastDot),
extension: filename.substring(lastDot + 1).toLowerCase(),
};
}
/**
* Join a base name and extension. Tolerant of either form ('pdf' or '.pdf').
* Returns just the name when extension is empty.
*/
function joinExtension(name, extension) {
var ext = (extension || '').replace(/^\./, '');
return ext ? name + '.' + ext : name;
}
// ── Public API ───────────────────────────────────────────────────────────
root.zddc = {
STATUSES: STATUSES,
isValidStatus: isValidStatus,
parseFilename: parseFilename,
parseFolder: parseFolder,
parseRevision: parseRevision,
formatFilename: formatFilename,
formatFolder: formatFolder,
compareRevisions: compareRevisions,
splitExtension: splitExtension,
joinExtension: joinExtension,
};
}(typeof window !== 'undefined' ? window : this));
// shared/zddc-source.js — source abstraction for tools that handle
// directory trees (classifier, mdedit, transmittal, browse, archive).
//
// Two backends:
//
// 1. Local — wraps a real FileSystemDirectoryHandle from the
// File System Access API. Reads + writes go through the
// FS Access API directly.
//
// 2. HTTP — talks to zddc-server's directory listing JSON
// (Accept: application/json) for reads and the file API
// (PUT/DELETE/POST X-ZDDC-Op) for writes. Implements a
// polyfill of the FS Access API surface area the tools
// use (kind, name, values(), getFileHandle, getDirectoryHandle,
// removeEntry, getFile, createWritable, queryPermission /
// requestPermission) so existing code works unchanged.
//
// The polyfill makes auto-load possible: when zddc-server serves
// a tool at /<dir>/<tool>.html, the tool detects HTTP mode at
// startup, builds an HttpDirectoryHandle for the tool's containing
// directory, and hands it to the existing openDirectory(handle)
// flow without ever showing the file picker.
//
// Renames inside a tool today are typically done as
// "write new + remove old". With HTTP-backed handles this becomes
// PUT + DELETE — non-atomic. Tools that prefer the atomic server
// MOVE should call window.zddc.source.moveFile(srcUrl, dstUrl)
// directly instead of going through the polyfill.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
var FA = window.FileSystemDirectoryHandle || null;
// -----------------------------------------------------------------
// HTTP file API helpers
// -----------------------------------------------------------------
function joinUrl(base, name, isDir) {
if (!base.endsWith('/')) base = base + '/';
return base + encodeURIComponent(name) + (isDir ? '/' : '');
}
// Server returns directory entries with a trailing "/" on names.
// Strip it for the FS Access API name surface.
function stripSlash(name) {
return name.endsWith('/') ? name.slice(0, -1) : name;
}
async function httpListing(url) {
var resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!resp.ok) {
var err = new Error('listing ' + url + ': HTTP ' + resp.status);
err.status = resp.status;
throw err;
}
var data = await resp.json();
if (!Array.isArray(data)) {
throw new Error('listing ' + url + ': non-array body');
}
return data;
}
async function httpExists(url) {
try {
var r = await fetch(url, { method: 'HEAD', credentials: 'same-origin' });
return r.ok;
} catch (_) {
return false;
}
}
// -----------------------------------------------------------------
// HttpFileHandle — FileSystemFileHandle polyfill
// -----------------------------------------------------------------
function makeFile(blob, name, modTime) {
return new File([blob], name, {
type: blob.type,
lastModified: modTime ? modTime.getTime() : Date.now()
});
}
function HttpFileHandle(url, name, size, modTime) {
this.kind = 'file';
this.name = name;
this._url = url;
this._size = size || 0;
this._modTime = modTime || null;
this._etag = null;
}
HttpFileHandle.prototype.getFile = async function () {
var resp = await fetch(this._url, { credentials: 'same-origin' });
if (!resp.ok) {
throw new Error('GET ' + this._url + ': ' + resp.status);
}
var etag = resp.headers.get('ETag');
if (etag) this._etag = etag.replace(/"/g, '');
var lm = resp.headers.get('Last-Modified');
var modTime = lm ? new Date(lm) : this._modTime;
var blob = await resp.blob();
return makeFile(blob, this.name, modTime);
};
HttpFileHandle.prototype.createWritable = async function () {
var chunks = [];
var handle = this;
return {
async write(data) {
if (data == null) return;
if (typeof data === 'object' && data && 'type' in data && data.type === 'write') {
chunks.push(data.data);
return;
}
if (typeof data === 'object' && data && 'type' in data) {
// seek/truncate not supported by HTTP backend
throw new Error('HttpFileHandle write op not supported: ' + data.type);
}
chunks.push(data);
},
async close() {
var blob = new Blob(chunks);
var resp = await fetch(handle._url, {
method: 'PUT',
body: blob,
credentials: 'same-origin'
});
if (!resp.ok) {
var body = '';
try { body = await resp.text(); } catch (_) { /* ignore */ }
throw new Error('PUT ' + handle._url + ': ' + resp.status + ' ' + body);
}
var et = resp.headers.get('ETag');
if (et) handle._etag = et.replace(/"/g, '');
handle._size = blob.size;
},
async abort() { chunks = []; }
};
};
HttpFileHandle.prototype.queryPermission = async function () { return 'granted'; };
HttpFileHandle.prototype.requestPermission = async function () { return 'granted'; };
HttpFileHandle.prototype.isHttp = true;
HttpFileHandle.prototype.url = function () { return this._url; };
// -----------------------------------------------------------------
// HttpDirectoryHandle — FileSystemDirectoryHandle polyfill
// -----------------------------------------------------------------
function HttpDirectoryHandle(url, name) {
this.kind = 'directory';
if (!url.endsWith('/')) url = url + '/';
this._url = url;
this.name = name || guessNameFromUrl(url);
}
function guessNameFromUrl(url) {
var u = url.replace(/\/+$/, '');
var slash = u.lastIndexOf('/');
return slash >= 0 ? decodeURIComponent(u.substring(slash + 1)) : u;
}
HttpDirectoryHandle.prototype.values = function () {
var url = this._url;
return (async function* () {
var entries;
try {
entries = await httpListing(url);
} catch (e) {
return;
}
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
var rawName = stripSlash(e.name);
// Listing entries can carry an explicit URL for virtual
// links (e.g. the reviewing-aggregator's received/+staged/
// entries point to canonical archive/+staging paths).
// Use it when present so navigation follows the listing's
// own routing rather than computing a synthetic child URL
// off the parent. Caddy-shape listings don't set url
// (or set it to a relative form) — joinUrl handles those.
var childUrl;
if (e.url && /^https?:\/\/|^\//.test(e.url)) {
// Absolute or root-relative: use as-is, normalised against origin.
var u = e.url;
if (u[0] === '/') {
u = location.origin + u;
}
childUrl = u;
} else {
childUrl = joinUrl(url, rawName, e.is_dir);
}
if (e.is_dir) {
yield new HttpDirectoryHandle(childUrl, rawName);
} else {
var modTime = e.mod_time ? new Date(e.mod_time) : null;
yield new HttpFileHandle(childUrl, rawName, e.size || 0, modTime);
}
}
})();
};
HttpDirectoryHandle.prototype.entries = function () {
var iter = this.values();
return (async function* () {
for (;;) {
var step = await iter.next();
if (step.done) return;
yield [step.value.name, step.value];
}
})();
};
HttpDirectoryHandle.prototype.keys = function () {
var iter = this.values();
return (async function* () {
for (;;) {
var step = await iter.next();
if (step.done) return;
yield step.value.name;
}
})();
};
HttpDirectoryHandle.prototype.getFileHandle = async function (name, opts) {
opts = opts || {};
var url = joinUrl(this._url, name, false);
var exists = await httpExists(url);
if (!exists && !opts.create) {
var err = new Error('NotFoundError: ' + name);
err.name = 'NotFoundError';
throw err;
}
return new HttpFileHandle(url, name, 0, null);
};
HttpDirectoryHandle.prototype.getDirectoryHandle = async function (name, opts) {
opts = opts || {};
var url = joinUrl(this._url, name, true);
if (opts.create) {
var resp = await fetch(url, {
method: 'POST',
headers: { 'X-ZDDC-Op': 'mkdir' },
credentials: 'same-origin'
});
if (!resp.ok && resp.status !== 200 && resp.status !== 201) {
throw new Error('mkdir ' + url + ': ' + resp.status);
}
}
return new HttpDirectoryHandle(url, name);
};
HttpDirectoryHandle.prototype.removeEntry = async function (name, opts) {
opts = opts || {};
// Probe listing to discover whether name is a file or directory.
var entries;
try {
entries = await httpListing(this._url);
} catch (e) {
throw new Error('removeEntry probe failed: ' + e.message);
}
var match = null;
for (var i = 0; i < entries.length; i++) {
if (stripSlash(entries[i].name) === name) {
match = entries[i];
break;
}
}
if (!match) {
var err = new Error('NotFoundError: ' + name);
err.name = 'NotFoundError';
throw err;
}
if (match.is_dir && !opts.recursive) {
// Server doesn't expose a recursive-delete endpoint yet,
// and FS Access API requires recursive=true to remove a
// non-empty directory anyway. Reject explicitly so the
// caller doesn't silently leave a stale tree behind.
var derr = new Error('Removing directories over HTTP is not supported');
derr.name = 'InvalidStateError';
throw derr;
}
var url = joinUrl(this._url, name, match.is_dir);
var resp = await fetch(url, { method: 'DELETE', credentials: 'same-origin' });
if (!resp.ok && resp.status !== 204) {
throw new Error('DELETE ' + url + ': ' + resp.status);
}
};
HttpDirectoryHandle.prototype.queryPermission = async function () { return 'granted'; };
HttpDirectoryHandle.prototype.requestPermission = async function () { return 'granted'; };
HttpDirectoryHandle.prototype.isHttp = true;
HttpDirectoryHandle.prototype.url = function () { return this._url; };
// -----------------------------------------------------------------
// Top-level helpers
// -----------------------------------------------------------------
// Strip a trailing tool .html (e.g. classifier.html) from a path
// to land on the "directory the tool was opened in".
function pathToDir(pathname) {
if (!pathname) return '/';
if (pathname.endsWith('/')) return pathname;
var slash = pathname.lastIndexOf('/');
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
}
// Probe the server-mode root for the current page. Returns:
//
// { handle: HttpDirectoryHandle, status: 200 } — server reachable, listing returned
// { handle: null, status: 403 } — server reachable but listing forbidden
// { handle: null, status: 0 } — not http(s), or server unreachable / non-JSON
//
// Tools that auto-load on startup distinguish 403 (show "no
// permission to list this directory" message) from 0 (fall back
// to local-mode welcome screen).
//
// Tool init pattern:
// if (location.protocol !== 'file:') {
// const r = await zddc.source.detectServerRoot();
// if (r.handle) await openDirectory(r.handle);
// else if (r.status === 403) showNoPermissionMessage();
// else showWelcome();
// } else { showWelcome(); }
async function detectServerRoot() {
if (typeof location === 'undefined') {
return { handle: null, status: 0 };
}
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return { handle: null, status: 0 };
}
var dirPath = pathToDir(location.pathname);
var url = location.origin + dirPath;
try {
await httpListing(url);
} catch (e) {
if (e && e.status === 403) {
return { handle: null, status: 403 };
}
return { handle: null, status: 0 };
}
return {
handle: new HttpDirectoryHandle(url, guessNameFromUrl(url)),
status: 200,
};
}
// Atomic file move. Path arguments are absolute URL paths
// (starting with /). Honors the file API's POST /op=move
// contract. Returns the new ETag.
async function moveFile(srcUrlPath, dstUrlPath, opts) {
opts = opts || {};
var headers = {
'X-ZDDC-Op': 'move',
'X-ZDDC-Destination': dstUrlPath
};
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch;
var resp = await fetch(srcUrlPath, {
method: 'POST',
headers: headers,
credentials: 'same-origin'
});
if (!resp.ok) {
var body = '';
try { body = await resp.text(); } catch (_) { /* ignore */ }
throw new Error('move ' + srcUrlPath + ' → ' + dstUrlPath + ': ' + resp.status + ' ' + body);
}
var et = resp.headers.get('ETag');
return et ? et.replace(/"/g, '') : null;
}
// Detect at construction time whether a directory handle is the
// HTTP polyfill or a real FS Access API handle. Useful for tools
// that want to take the optimized path (e.g. atomic moveFile)
// when in HTTP mode rather than the FS-API copy+remove fallback.
function isHttpHandle(handle) {
return !!(handle && handle.isHttp === true);
}
window.zddc.source = {
HttpDirectoryHandle: HttpDirectoryHandle,
HttpFileHandle: HttpFileHandle,
detectServerRoot: detectServerRoot,
moveFile: moveFile,
isHttpHandle: isHttpHandle,
// Lower-level helpers exposed for tools that want to call the
// server directly without going through the polyfill.
httpListing: httpListing,
joinUrl: joinUrl,
stripSlash: stripSlash
};
})();
/**
* ZDDC shared theme toggle — light / dark / auto.
* Persists choice to localStorage under 'zddc-theme'.
* Works with all four tools regardless of their module pattern.
* Expects: #theme-btn in the DOM (optional — skips gracefully if absent).
*
* Theme cycle: auto → light → dark → auto …
* 'auto' honours the OS prefers-color-scheme media query (CSS handles it).
* 'light' sets data-theme="light" on <html> (overrides dark media query).
* 'dark' sets data-theme="dark" on <html>.
*/
(function () {
'use strict';
var STORAGE_KEY = 'zddc-theme';
var THEMES = ['auto', 'light', 'dark'];
var LABELS = {
auto: '◐',
light: '☀',
dark: '☾'
};
var TITLES = {
auto: 'Theme: auto (follows OS)',
light: 'Theme: light',
dark: 'Theme: dark'
};
function load() {
var stored = localStorage.getItem(STORAGE_KEY);
return THEMES.indexOf(stored) !== -1 ? stored : 'auto';
}
function apply(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function save(theme) {
try { localStorage.setItem(STORAGE_KEY, theme); } catch (e) {}
}
function updateButton(btn, theme) {
btn.textContent = LABELS[theme];
btn.title = TITLES[theme];
btn.setAttribute('aria-label', TITLES[theme]);
}
function next(theme) {
return THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
}
function init() {
var current = load();
apply(current);
var btn = document.getElementById('theme-btn');
if (!btn) { return; }
updateButton(btn, current);
btn.addEventListener('click', function () {
current = next(current);
apply(current);
save(current);
updateButton(btn, current);
});
}
/* Apply theme immediately (before DOM ready) to avoid flash */
apply(load());
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
// shared/toast.js — non-blocking notification helper available to every
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
// local showToast (classifier/js/excel.js); promoted here so tools that
// today use alert() or silent console.error can switch to a uniform
// non-blocking surface.
//
// Usage:
// window.zddc.toast('Saved.', 'success');
// window.zddc.toast('Could not load: ' + err.message, 'error');
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
//
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
// see ARCHITECTURE.md for the convention.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Don't overwrite if a tool defined its own first.
if (typeof window.zddc.toast === 'function') return;
var DEFAULT_DURATION_MS = 5000;
var FADE_MS = 300;
function toast(message, level, opts) {
opts = opts || {};
var lvl = (level === 'success' || level === 'error' ||
level === 'warning') ? level : 'info';
// Single-toast policy: dismiss any existing toast immediately
// so the new one is always the most recent. Matches the
// classifier's prior behavior and avoids stack-of-toasts UX.
var existing = document.querySelector('.zddc-toast');
if (existing) existing.remove();
var el = document.createElement('div');
el.className = 'zddc-toast zddc-toast--' + lvl;
// ARIA: errors get assertive (interrupts SR queue), others polite.
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
el.textContent = message == null ? '' : String(message);
document.body.appendChild(el);
var dur = typeof opts.durationMs === 'number' ?
opts.durationMs : DEFAULT_DURATION_MS;
var timer = setTimeout(function () {
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, FADE_MS);
}, dur);
// Click-to-dismiss. Useful for sticky errors the user wants gone.
el.addEventListener('click', function () {
clearTimeout(timer);
if (el.parentNode) el.parentNode.removeChild(el);
});
return el;
}
window.zddc.toast = toast;
})();
// shared/nav.js — lateral navigation strip across the four canonical
// project stages (archive · working · staging · reviewing). Renders
// only when:
// 1. location.protocol is http: or https: (online — file:// has no
// project structure to navigate within), AND
// 2. a project segment can be detected from location.pathname (the
// first path segment, when it isn't a tool HTML file).
//
// The strip is inserted as a sibling of <header class="app-header">
// on DOMContentLoaded — no template changes required. Each tool just
// needs ../shared/nav.{js,css} in its build.sh.
//
// Stage URLs follow the canonical workflow folders documented at
// zddc.varasys.io/reference.html#transmittal-workflow:
// archive → <project>/archive.html (archive tool, project-root mode)
// working → <project>/working/ (directory listing → mdedit auto-serves)
// staging → <project>/staging/ (directory listing → transmittal auto-serves)
// reviewing → <project>/reviewing/ (directory listing)
//
// If a deployment doesn't have one of these folders the link will 404 —
// the strip is convention-driven, not probed. Operators on non-standard
// layouts can override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
var STAGES = [
{ key: 'archive', label: 'Archive', target: 'archive.html' },
{ key: 'working', label: 'Working', target: 'working/' },
{ key: 'staging', label: 'Staging', target: 'staging/' },
{ key: 'reviewing', label: 'Reviewing', target: 'reviewing/' },
];
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
// At deployment root (e.g. /archive.html?projects=A,B or
// /index.html) the first segment is a tool HTML — no single
// project to scope the strip to.
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
// <project>/working/... | staging/... | reviewing/... | archive/...
for (var i = 0; i < STAGES.length; i++) {
if (second === STAGES[i].key) return STAGES[i].key;
}
// <project>/archive.html → still the archive stage
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function buildStrip(project, active) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < STAGES.length; i++) {
var s = STAGES[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.target;
a.textContent = s.label;
if (s.key === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < STAGES.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mount() {
if (!shouldRender()) return;
var header = document.querySelector('.app-header');
if (!header) return;
// Don't double-mount if a tool's main.js calls us a second time.
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return;
}
var project = projectSegment(location.pathname);
var active = currentStage(location.pathname);
var strip = buildStrip(project, active);
// Mount ABOVE the header — the strip is project-level chrome
// (where in the project), the header is tool-level chrome (which
// tool, theme, help). Reading order matches outer-to-inner scope.
header.parentNode.insertBefore(strip, header);
}
// Expose for tests + opt-out.
window.zddc.nav = {
mount: mount,
// Internals visible for unit tests; do not call from tools.
_projectSegment: projectSegment,
_currentStage: currentStage,
_stages: STAGES,
// Set to true before DOMContentLoaded to suppress mounting on
// deployments where the canonical folder layout doesn't apply.
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
// every tool's header into a clickable link. The destination is the
// nearest "home" the user can sensibly back out to:
//
// file:// → no wrap (no server home)
// http(s)://host/ → wrap, href = /
// http(s)://host/<tool>.html (deployment root)→ wrap, href = /
// http(s)://host/<project>/... → wrap, href = /<project>
//
// When inside a project, the logo takes the user to the project
// landing (synthetic page with the four lifecycle-stage cards + MDL
// instructions). When at the deployment root, the logo points at /
// (the project picker). Offline, the logo stays decorative — there's
// no real "home" to go to.
//
// Mounts as a sibling-replacement on DOMContentLoaded: wraps the
// existing logo SVG in an <a>, preserving classes and attributes.
// Idempotent: re-mounting on an already-wrapped logo is a no-op.
//
// Tools that want to override (e.g. a deployment that pins logo to
// an external URL) can set window.zddc.logo.disabled = true before
// DOMContentLoaded and inject their own anchor.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.logo) return;
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
// Tool HTMLs at the deployment root (index.html, archive.html
// with ?projects=...) don't carry a project segment.
if (first.indexOf('.') !== -1) return null;
return first;
}
function targetHref() {
if (typeof location === 'undefined') return null;
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return null;
}
if (window.zddc.logo && window.zddc.logo.disabled) return null;
var seg = projectSegment(location.pathname);
return seg ? '/' + encodeURIComponent(seg) : '/';
}
function mount() {
var logo = document.querySelector('.app-header__logo');
if (!logo) return;
// Already wrapped (template-supplied anchor, or a previous mount).
if (logo.parentElement && logo.parentElement.tagName === 'A' &&
logo.parentElement.classList.contains('app-header__logo-link')) {
return;
}
var href = targetHref();
if (!href) return;
var a = document.createElement('a');
a.href = href;
a.className = 'app-header__logo-link';
var label = href === '/' ? 'ZDDC home' : 'Project home';
a.title = label;
a.setAttribute('aria-label', label);
logo.parentNode.insertBefore(a, logo);
a.appendChild(logo);
}
window.zddc.logo = {
mount: mount,
// Test seam.
_projectSegment: projectSegment,
_targetHref: targetHref,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
/**
* ZDDC shared help panel — open/close logic.
* Works with all four tools regardless of their module pattern.
* Expects: #help-btn, #help-panel, #help-panel-close in the DOM.
*/
(function () {
'use strict';
function init() {
var helpBtn = document.getElementById('help-btn');
var panel = document.getElementById('help-panel');
var closeBtn = document.getElementById('help-panel-close');
if (!helpBtn || !panel) { return; }
function isOpen() { return !panel.hidden; }
function openPanel() {
panel.hidden = false;
document.body.classList.add('help-open');
}
function closePanel() {
panel.hidden = true;
document.body.classList.remove('help-open');
}
helpBtn.addEventListener('click', function () {
if (isOpen()) { closePanel(); } else { openPanel(); }
});
if (closeBtn) {
closeBtn.addEventListener('click', closePanel);
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen()) { closePanel(); }
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
// mode.js — picks table-mode vs form-mode at boot time and unhides the
// matching container. Both apps (tablesApp, formApp) ship in the same
// bundle but each only paints when its container is visible.
//
// Decision rule:
// /<dir>/table.html → table mode
// /<dir>/form.html → form mode (empty / create)
// /<dir>/<id>.yaml.html → form mode (re-edit)
// anything else / file:// → table mode (legacy default; tables tool
// was the original consumer of this bundle)
//
// In offline / file:// mode the inline-context placeholders decide:
// whichever blob is non-empty wins. Tests that inject only
// #form-context render in form mode; tests that inject only
// #table-context render in table mode.
(function () {
'use strict';
function modeFromUrl() {
const path = String((typeof location !== 'undefined' && location.pathname) || '');
if (/\/form\.html$/.test(path) || /\.yaml\.html$/.test(path)) {
return 'form';
}
if (/\/table\.html$/.test(path)) {
return 'table';
}
return null; // unknown — will be decided once DOM is parsed.
}
function readInline(id) {
const el = document.getElementById(id);
if (!el) return null;
try {
return JSON.parse(el.textContent || '{}');
} catch (_) {
return null;
}
}
function modeFromInline() {
// file:// or unrecognised URL — whichever inline-context blob is
// non-empty wins. Tests that inject only #form-context render in
// form mode; tests that inject only #table-context render in
// table mode. Default to table for legacy compatibility.
const formCtx = readInline('form-context');
if (formCtx && Object.keys(formCtx).length > 0) {
return 'form';
}
return 'table';
}
// Best-effort synchronous decision so per-app boot guards can read
// window.zddcMode without waiting for DOM. URL-based decision is
// always known up-front; inline-context fallback only matters for
// file:// and is finalized at DOMContentLoaded.
window.zddcMode = modeFromUrl() || 'table';
function activate() {
if (modeFromUrl() == null) {
window.zddcMode = modeFromInline();
}
const tableEl = document.getElementById('table-mode');
const formEl = document.getElementById('form-mode');
if (window.zddcMode === 'form' && formEl) {
formEl.hidden = false;
} else if (tableEl) {
tableEl.hidden = false;
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', activate, { once: true });
} else {
activate();
}
})();
(function (global) {
'use strict';
if (global.tablesApp) {
return;
}
global.tablesApp = {
context: null,
state: {
rows: [],
sort: [],
filter: {},
// Editor-mode state (Phase 1):
// selected: {row: rowId, col: field} | null — currently
// focused cell. row is the row's id (or rowsRel for the
// row file path); col is the column's `field`.
// editing: bool — whether a cell-editor input is mounted.
// drafts: {rowId: {field: value, ...}, ...} — uncommitted
// edits, displayed in lieu of row.data while present.
// Cleared per-row when that row's PUT succeeds (Phase 3).
// range: {anchor: {row, col}, focus: {row, col}} | null
// — multi-cell range selection (Phase 5).
selected: null,
editing: false,
range: null,
drafts: {}
},
modules: {}
};
})(window);
(function (app) {
'use strict';
// load() resolves to the table context the rest of the app renders:
// { title?, description?, columns, rows, defaults? }
//
// Two paths:
//
// 1. Inline JSON (test seam, and also any host that wants to
// pre-render a context server-side): if #table-context parses
// to a non-empty object, return it as-is.
//
// 2. File-backed walk (the real-world path served by zddc-server):
// page is at /<dir>/table.html — fetch <dir>/table.yaml,
// list every other *.yaml in <dir> as a row file (filtering
// out table.yaml and form.yaml so they don't appear as rows),
// parse each, and assemble the same shape. The whole table
// lives in one directory.
//
// file:// mode without a directory handle is unsupported in v1 — the
// walk only runs against http(s). file:// users must either inject an
// inline context (tests) or open the page through zddc-server.
async function load() {
const inline = readInlineContext();
if (inline && Object.keys(inline).length > 0) {
return inline;
}
if (typeof location !== 'undefined' &&
(location.protocol === 'http:' || location.protocol === 'https:')) {
try {
const walked = await walkServer();
if (walked) {
return walked;
}
} catch (err) {
console.error('[tables] failed to load table from server', err);
showStatus('Could not load table: ' + (err && err.message ? err.message : err));
}
}
return {};
}
function readInlineContext() {
const el = document.getElementById('table-context');
if (!el) {
return null;
}
try {
return JSON.parse(el.textContent || '{}');
} catch (err) {
console.error('[tables] failed to parse #table-context', err);
return null;
}
}
function showStatus(msg) {
const el = document.getElementById('table-status');
if (!el) return;
el.textContent = msg;
el.hidden = false;
}
async function walkServer() {
const source = window.zddc && window.zddc.source;
if (!source) {
throw new Error('zddc.source not available');
}
const tableName = tableNameFromUrl(location.pathname);
if (!tableName) {
throw new Error('Unrecognized table URL: ' + location.pathname);
}
const probe = await source.detectServerRoot();
if (!probe.handle) {
throw new Error(probe.status === 403
? 'No permission to list this directory'
: 'Server unreachable');
}
const dir = probe.handle;
// Spec lives at <currentdir>/table.yaml — the page URL is
// <currentdir>/table.html, so the spec is right next door.
const spec = await readYaml(dir, 'table.yaml');
if (!spec || !Array.isArray(spec.columns)) {
throw new Error('Spec table.yaml missing columns[]');
}
// Optional row schema from <dir>/form.yaml — same JSON Schema
// the form-mode renderer uses. Phase 2 derives per-cell editor
// widgets from it (text/number/date/select/checkbox).
// Best-effort: a directory with only table.yaml still renders
// as a sortable/filterable table; cells fall back to plain
// text inputs without per-property hints.
let rowSchema = null;
try {
const formSpec = await readYaml(dir, 'form.yaml');
if (formSpec && formSpec.schema) {
rowSchema = formSpec.schema;
}
} catch (_) {
// form.yaml missing or unreadable; carry on without it.
}
// Rows are every *.yaml in <currentdir> EXCEPT the spec
// (table.yaml) and the row-edit form (form.yaml). They live
// in the same directory by design — copying the directory
// copies the whole table.
const rows = await readRows(dir, '', tableName);
return {
title: spec.title,
description: spec.description,
columns: spec.columns,
defaults: spec.defaults,
rowSchema: rowSchema,
rows: rows
};
}
function tableNameFromUrl(pathname) {
// /<dir>/.../<rowsdir>/table.html → name is the rows-dir's
// basename.
const m = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
return m ? m[1] : null;
}
function stripDotSlash(p) {
let out = String(p || '');
if (out.startsWith('./')) out = out.slice(2);
if (out.startsWith('/')) out = out.slice(1);
if (out.endsWith('/')) out = out.slice(0, -1);
return out;
}
async function readYaml(dir, relPath) {
const fileHandle = await resolveFile(dir, relPath);
const file = await fileHandle.getFile();
const text = await file.text();
if (!window.jsyaml) {
throw new Error('js-yaml not loaded');
}
return window.jsyaml.load(text);
}
// Walk a "/"-separated relative path under dir, returning the
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
async function resolveFile(dir, relPath) {
const parts = relPath.split('/').filter(Boolean);
if (parts.length === 0) {
throw new Error('Empty file path');
}
const fileName = parts.pop();
let cur = dir;
for (let i = 0; i < parts.length; i++) {
cur = await cur.getDirectoryHandle(parts[i]);
}
return cur.getFileHandle(fileName);
}
async function resolveDirectory(dir, relPath) {
const parts = relPath.split('/').filter(Boolean);
let cur = dir;
for (let i = 0; i < parts.length; i++) {
cur = await cur.getDirectoryHandle(parts[i]);
}
return cur;
}
async function readRows(rowsDir, _rowsRel, _tableName) {
const rows = [];
for await (const entry of rowsDir.values()) {
if (entry.kind !== 'file') continue;
if (!entry.name.endsWith('.yaml')) continue;
// Skip the spec and the row-edit form — they live alongside
// the rows but aren't rows themselves.
if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
try {
const handle = await rowsDir.getFileHandle(entry.name);
const file = await handle.getFile();
const data = window.jsyaml.load(await file.text());
rows.push({
url: rowEditUrl(entry.name),
// Underlying YAML URL — strip the trailing .html
// from the form-mode re-edit URL. Phase 3 PUTs to
// this URL with If-Match: <etag> for optimistic
// concurrency.
yamlUrl: rowEditUrl(entry.name).replace(/\.html$/, ''),
data: data || {},
// ETag captured by HttpFileHandle.getFile from the
// server's response header. null in offline / file://
// mode (no HTTP roundtrip happened).
etag: handle._etag || null,
editable: true
});
} catch (err) {
console.warn('[tables] skipping unparseable row', entry.name, err);
}
}
return rows;
}
// Re-edit URL for one row. Page is at /<dir>/table.html; row file
// lives at /<dir>/<basename>.yaml; form re-edit URL is
// /<dir>/<basename>.yaml.html — same directory.
function rowEditUrl(rowFileName) {
const pageDir = location.pathname.replace(/\/table\.html$/, '/');
return pageDir + rowFileName + '.html';
}
app.modules.context = { load: load };
})(window.tablesApp);
(function (app) {
'use strict';
const util = {};
util.h = function (tag, attrs) {
const el = document.createElement(tag);
if (attrs) {
for (const k of Object.keys(attrs)) {
const v = attrs[k];
if (v == null || v === false) {
continue;
}
if (k === 'className') {
el.className = v;
} else if (k.length > 2 && k.slice(0, 2) === 'on' && typeof v === 'function') {
el.addEventListener(k.slice(2).toLowerCase(), v);
} else if (v === true) {
el.setAttribute(k, '');
} else {
el.setAttribute(k, v);
}
}
}
for (let i = 2; i < arguments.length; i++) {
const c = arguments[i];
if (c == null || c === false) {
continue;
}
if (typeof c === 'string' || typeof c === 'number') {
el.appendChild(document.createTextNode(String(c)));
} else {
el.appendChild(c);
}
}
return el;
};
// Resolve a column's `field` against a row data object.
// - "" or "/" → the whole object
// - "/foo/bar" → JSON Pointer (RFC 6901) lookup
// - "foo" → top-level key
util.resolveField = function (data, field) {
if (data == null) {
return undefined;
}
if (!field || field === '/') {
return data;
}
if (field.charAt(0) !== '/') {
return data[field];
}
const segments = field.split('/').slice(1).map(function (s) {
return s.replace(/~1/g, '/').replace(/~0/g, '~');
});
let cur = data;
for (let i = 0; i < segments.length; i++) {
if (cur == null) {
return undefined;
}
if (Array.isArray(cur)) {
const idx = parseInt(segments[i], 10);
if (Number.isNaN(idx)) {
return undefined;
}
cur = cur[idx];
} else if (typeof cur === 'object') {
cur = cur[segments[i]];
} else {
return undefined;
}
}
return cur;
};
// Format a raw cell value per column's `format` hint.
util.formatCell = function (value, format) {
if (value == null || value === '') {
return '';
}
if (format === 'date') {
const d = new Date(value);
if (!isNaN(d.getTime())) {
return d.toISOString().slice(0, 10);
}
return String(value);
}
if (format === 'datetime') {
const d = new Date(value);
if (!isNaN(d.getTime())) {
return d.toLocaleString();
}
return String(value);
}
if (format === 'number') {
const n = Number(value);
if (Number.isFinite(n)) {
return n.toLocaleString();
}
return String(value);
}
if (format === 'bool' || typeof value === 'boolean') {
return value ? '✓' : '';
}
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch (e) {
return String(value);
}
}
return String(value);
};
// Compare two cell values for sorting. null/undefined sort last.
// Numbers compared numerically, dates compared as Date, otherwise string compare.
util.compareCells = function (a, b, format) {
const aMissing = a == null || a === '';
const bMissing = b == null || b === '';
if (aMissing && bMissing) {
return 0;
}
if (aMissing) {
return 1;
}
if (bMissing) {
return -1;
}
if (format === 'date' || format === 'datetime') {
const da = new Date(a).getTime();
const db = new Date(b).getTime();
if (!isNaN(da) && !isNaN(db)) {
return da - db;
}
}
if (format === 'number' || (typeof a === 'number' && typeof b === 'number')) {
const na = Number(a);
const nb = Number(b);
if (Number.isFinite(na) && Number.isFinite(nb)) {
return na - nb;
}
}
const sa = String(a).toLowerCase();
const sb = String(b).toLowerCase();
if (sa < sb) return -1;
if (sa > sb) return 1;
return 0;
};
app.modules.util = util;
})(window.tablesApp);
(function (app) {
'use strict';
// A filter is per-column and has one of two shapes:
// - free-text: { kind: 'contains', value: '<string>' }
// - enum: { kind: 'enum', value: ['<choice>', ...] }
// An empty value (empty string or empty array) matches everything.
//
// The render layer only emits the free-text shape; enum is kept here
// for back-compat with any inline-context test fixtures that seed
// filter state directly. defaultFilterFor always returns text.
function isEnumColumn(col) {
return Array.isArray(col.enum) && col.enum.length > 0;
}
function defaultFilterFor(_col) {
return { kind: 'contains', value: '' };
}
function rowMatches(filter, cellValue) {
if (filter.kind === 'enum') {
if (!Array.isArray(filter.value) || filter.value.length === 0) {
return true;
}
const s = cellValue == null ? '' : String(cellValue);
return filter.value.indexOf(s) !== -1;
}
// contains
if (!filter.value) {
return true;
}
const needle = String(filter.value).toLowerCase();
const hay = cellValue == null ? '' : String(cellValue).toLowerCase();
return hay.indexOf(needle) !== -1;
}
function isEmpty(filter) {
if (filter.kind === 'enum') {
return !Array.isArray(filter.value) || filter.value.length === 0;
}
return !filter.value;
}
function apply(rows, columns, filterMap, resolveField) {
return rows.filter(function (row) {
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const filter = filterMap[col.field];
if (!filter || isEmpty(filter)) {
continue;
}
const cellValue = resolveField(row.data, col.field);
if (!rowMatches(filter, cellValue)) {
return false;
}
}
return true;
});
}
app.modules.filters = {
defaultFilterFor: defaultFilterFor,
isEnumColumn: isEnumColumn,
isEmpty: isEmpty,
apply: apply
};
})(window.tablesApp);
(function (app) {
'use strict';
// Sort state is an ordered list of {field, dir} keys; the first is
// primary, additional keys break ties.
function defaultsFromContext(ctx) {
const defaults = ctx.defaults || {};
if (Array.isArray(defaults.sort) && defaults.sort.length > 0) {
return defaults.sort.slice();
}
// Fall back to any column with `sort:` set.
const fromCols = (ctx.columns || []).filter(function (c) { return c.sort; });
if (fromCols.length > 0) {
return fromCols.map(function (c) {
const dir = c.sort === 'desc' ? 'desc' : 'asc';
return { field: c.field, dir: dir };
});
}
return [];
}
function findColumn(columns, field) {
for (let i = 0; i < columns.length; i++) {
if (columns[i].field === field) {
return columns[i];
}
}
return null;
}
// Click handler for a header: cycle the sort state for `field`.
// - Not currently a sort key → add as primary, asc
// - Currently primary asc → flip to desc
// - Currently primary desc → remove
// - Currently secondary → promote to primary, asc
// Shift-click is meant for additional accumulation but we keep the
// single-click semantics simple; advanced multi-sort can be a
// follow-up.
function cycle(state, field, multi) {
const idx = state.findIndex(function (s) { return s.field === field; });
if (multi) {
if (idx === -1) {
return state.concat([{ field: field, dir: 'asc' }]);
}
const cur = state[idx];
if (cur.dir === 'asc') {
const next = state.slice();
next[idx] = { field: field, dir: 'desc' };
return next;
}
return state.slice(0, idx).concat(state.slice(idx + 1));
}
if (idx === -1) {
return [{ field: field, dir: 'asc' }];
}
if (idx === 0) {
const cur = state[0];
if (cur.dir === 'asc') {
return [{ field: field, dir: 'desc' }];
}
return [];
}
return [{ field: field, dir: 'asc' }];
}
function apply(rows, sortState, columns, util) {
if (!sortState || sortState.length === 0) {
return rows;
}
const out = rows.slice();
out.sort(function (a, b) {
for (let i = 0; i < sortState.length; i++) {
const key = sortState[i];
const col = findColumn(columns, key.field);
const fmt = col ? col.format : '';
const av = util.resolveField(a.data, key.field);
const bv = util.resolveField(b.data, key.field);
const cmp = util.compareCells(av, bv, fmt);
if (cmp !== 0) {
return key.dir === 'desc' ? -cmp : cmp;
}
}
return 0;
});
return out;
}
function indicator(sortState, field) {
for (let i = 0; i < sortState.length; i++) {
if (sortState[i].field === field) {
const arrow = sortState[i].dir === 'desc' ? ' ▼' : ' ▲';
if (sortState.length > 1) {
return arrow + (i + 1);
}
return arrow;
}
}
return '';
}
app.modules.sort = {
defaultsFromContext: defaultsFromContext,
cycle: cycle,
apply: apply,
indicator: indicator
};
})(window.tablesApp);
// editor.js — Phase 1 of editable-cell mode.
//
// Owns the cell-selection + per-cell edit lifecycle. Implements the
// W3C ARIA grid-pattern keyboard semantics:
//
// - Arrow keys move the selected cell.
// - Tab / Shift-Tab move right / left, wrapping to next / prev row.
// - Enter, F2, double-click, or any printable character enter edit
// mode (Enter and F2 keep the existing value; printable chars
// replace it; double-click opens with the existing value).
// - In edit mode: Enter commits and moves down, Tab commits and
// moves right, Escape cancels (restoring the prior value), blur
// commits.
//
// Roving tabindex: only the selected cell carries tabindex=0; all
// others are tabindex=-1. This makes the grid a single tab-stop in
// the page's tab order, which is the documented spreadsheet UX.
//
// Edits in this phase live in app.state.drafts and never hit the
// network — Phase 3 wires the row-blur PUT.
(function (app) {
'use strict';
// --- Helpers ------------------------------------------------------
function tableEl() { return document.getElementById('table-root'); }
function cellAt(r, c) { return cellsByRowCol(r, c); }
// The displayed table is filtered+sorted; selection is keyed by
// VISIBLE row index, not row id, so arrow keys behave intuitively
// even after sort / filter changes (the cell at row 3 column 2
// stays at row 3 column 2 even if the underlying row id moved).
// This is how Excel and Google Sheets behave too.
function cellsByRowCol(r, c) {
const t = tableEl();
if (!t) return null;
const tbody = t.querySelector('tbody');
if (!tbody) return null;
const tr = tbody.children[r];
if (!tr) return null;
return tr.querySelector('[role="gridcell"][data-col-idx="' + c + '"]');
}
function isPrintableKey(ev) {
// A "printable" key produces a single character of text — e.g.
// 'a', '7', '$'. Function keys, arrows, modifiers etc. either
// have multi-char `key` values ('ArrowDown') or are non-text.
// ev.ctrlKey / metaKey suppress so Cmd-A et al. don't trigger
// edit mode.
if (ev.key.length !== 1) return false;
if (ev.ctrlKey || ev.metaKey || ev.altKey) return false;
return true;
}
function rowCount() {
const t = tableEl();
if (!t) return 0;
return t.querySelectorAll('tbody > tr').length;
}
function colCount() {
const cols = (app.context && app.context.columns) || [];
return Array.isArray(cols) ? cols.length : 0;
}
function colAt(c) {
const cols = (app.context && app.context.columns) || [];
return cols[c] || null;
}
function rowDataAt(r) {
// The visible row at index r. Walk the rendered tbody to find
// its data-row-id, then look up the row in app.state.rows.
// app.state.rows holds the SORTED+FILTERED current view (kept
// in sync by main.js paint()).
const t = tableEl();
if (!t) return null;
const tr = t.querySelectorAll('tbody > tr')[r];
if (!tr) return null;
const rowId = tr.getAttribute('data-row-id');
if (rowId == null) return null;
const all = app.state.rows || [];
for (let i = 0; i < all.length; i++) {
if (rowKey(all[i]) === rowId) {
return all[i];
}
}
return null;
}
function rowKey(row) {
// Stable per-row identity. Each context row has a `url` (the
// <id>.yaml.html re-edit URL); the file basename inside that
// URL is unique per directory and survives sort/filter.
if (!row || !row.url) return '';
return row.url;
}
// --- Draft buffer -------------------------------------------------
function getDraft(rowId, field) {
const r = app.state.drafts[rowId];
if (!r) return undefined;
return r[field];
}
function setDraft(rowId, field, value) {
if (!app.state.drafts[rowId]) {
app.state.drafts[rowId] = {};
}
app.state.drafts[rowId][field] = value;
}
function clearDraftField(rowId, field) {
const r = app.state.drafts[rowId];
if (!r) return;
delete r[field];
if (Object.keys(r).length === 0) {
delete app.state.drafts[rowId];
}
}
function effectiveCellValue(row, col) {
// Display draft value if present; otherwise the row's stored
// value. Used by render to keep the visible cell content in
// sync with uncommitted edits.
const drafted = getDraft(rowKey(row), col.field);
if (drafted !== undefined) {
return drafted;
}
return app.modules.util.resolveField(row.data, col.field);
}
// --- Selection (roving tabindex) ----------------------------------
function setSelected(r, c, opts) {
opts = opts || {};
const total = rowCount();
const cols = colCount();
if (total === 0 || cols === 0) {
app.state.selected = null;
notifySelectionChanged();
return;
}
if (r < 0) r = 0;
if (r > total - 1) r = total - 1;
if (c < 0) c = 0;
if (c > cols - 1) c = cols - 1;
const t = tableEl();
if (t) {
const all = t.querySelectorAll('[role="gridcell"]');
for (let i = 0; i < all.length; i++) {
all[i].setAttribute('tabindex', '-1');
all[i].classList.remove('zddc-table__cell--selected');
}
}
const target = cellAt(r, c);
if (target) {
target.setAttribute('tabindex', '0');
target.classList.add('zddc-table__cell--selected');
if (!opts.noFocus) {
target.focus({ preventScroll: false });
}
}
app.state.selected = { row: r, col: c };
// Plain selection moves clear the multi-cell range. Range
// operations (Shift+click, Shift+arrow) pass keepRange so the
// anchor stays put while the focus cell moves.
if (!opts.keepRange) {
clearRange();
}
notifySelectionChanged();
}
function notifySelectionChanged() {
// Phase 3 wires the row-blur save trigger here. save module is
// optional in test fixtures that don't include it.
const save = app.modules.save;
if (save && typeof save.onSelectionChanged === 'function') {
save.onSelectionChanged(app.state.selected);
}
}
function clearSelection() {
const t = tableEl();
if (t) {
const all = t.querySelectorAll('[role="gridcell"]');
for (let i = 0; i < all.length; i++) {
all[i].setAttribute('tabindex', '-1');
all[i].classList.remove('zddc-table__cell--selected');
}
}
app.state.selected = null;
}
// --- Edit mode ----------------------------------------------------
function enterEdit(initial) {
if (!app.state.selected) return;
if (app.state.editing) return;
const { row: r, col: c } = app.state.selected;
const cell = cellAt(r, c);
if (!cell) return;
const row = rowDataAt(r);
const col = colAt(c);
if (!row || !col) return;
const propSchema = propertySchemaFor(col);
// Complex-type cells (nested object, generic array, oneOf)
// can't be inline-edited cleanly — punt to the row's form
// editor in a side panel / new page. Phase 2 ships the
// navigation; Phase 5 may add a side-panel mount.
if (isComplexSchema(propSchema)) {
navigateToRowForm(row);
return;
}
const currentValue = effectiveCellValue(row, col);
const widget = makeWidget(propSchema, col, initial != null ? initial : currentValue);
const inputEl = widget.element;
inputEl.classList.add('zddc-table__cell-input');
inputEl.setAttribute('aria-label', 'Edit ' + (col.title || col.field));
// Replace the cell's text content with the editor widget.
// Stash the original text in dataset so cancel can restore it
// verbatim without re-running the formatCell logic.
cell.setAttribute('data-display', cell.textContent || '');
cell.textContent = '';
cell.appendChild(inputEl);
widget.focus();
app.state.editing = true;
function commit() {
if (!app.state.editing) return;
const newValue = widget.getValue();
const oldRaw = app.modules.util.resolveField(row.data, col.field);
// Compare by JSON-string equality so number 42 == "42"
// entered into a number input doesn't false-positive as
// a change. resolveField already returns the raw typed
// value from row.data.
if (sameValue(oldRaw, newValue)) {
clearDraftField(rowKey(row), col.field);
} else {
// Capture the prior draft value (or stored value if
// no draft) for undo. Lets Ctrl+Z restore intermediate
// state: e.g. typing A → B → C and undoing returns to
// B, not all the way back to the row's stored value.
const priorDraft = getDraft(rowKey(row), col.field);
const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw;
setDraft(rowKey(row), col.field, newValue);
const undoMod = app.modules.undo;
if (undoMod) {
undoMod.push({
cells: [{
rowId: rowKey(row),
field: col.field,
oldValue: undoOld,
newValue: newValue,
}],
});
}
}
tearDown(newValue);
}
function cancel() {
tearDown(null); // null = restore from data-display, no draft change
}
function tearDown(displayValue) {
inputEl.removeEventListener('keydown', onKey);
inputEl.removeEventListener('blur', onBlur);
const display = (displayValue !== undefined && displayValue !== null)
? renderableText(displayValue, col)
: (cell.getAttribute('data-display') || '');
cell.removeAttribute('data-display');
cell.textContent = display;
app.state.editing = false;
cell.focus({ preventScroll: false });
}
function onKey(ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
ev.stopPropagation(); // don't let the table's onCellKey re-handle it
commit();
setSelected(r + 1, c);
} else if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
cancel();
} else if (ev.key === 'Tab') {
ev.preventDefault();
ev.stopPropagation();
commit();
if (ev.shiftKey) {
moveSelection('left-wrap');
} else {
moveSelection('right-wrap');
}
}
// Other keys: stay in edit mode, let the input handle them.
}
function onBlur(_ev) {
// Blur (focus moved elsewhere). Commit any pending value.
// Schedule via setTimeout(0) so a programmatic refocus by
// tearDown→cell.focus doesn't re-fire blur during teardown.
if (app.state.editing) {
commit();
}
}
inputEl.addEventListener('keydown', onKey);
inputEl.addEventListener('blur', onBlur);
}
function renderableText(value, col) {
return app.modules.util.formatCell(value, col.format);
}
// --- Schema → editor widget factory --------------------------------
function propertySchemaFor(col) {
// Walk the row schema for this column's field. Returns null
// when no schema is present (best-effort: cells fall back to
// plain text editors). Supports a single dot-separated path
// — `properties.a.properties.b` for `field: "a.b"` — to mirror
// the existing util.resolveField conventions.
const ctx = app.context || {};
if (!ctx.rowSchema) return null;
const parts = String(col.field || '').split('.').filter(Boolean);
let s = ctx.rowSchema;
for (let i = 0; i < parts.length; i++) {
if (!s || !s.properties || !s.properties[parts[i]]) return null;
s = s.properties[parts[i]];
}
return s;
}
function isComplexSchema(s) {
if (!s) return false;
if (Array.isArray(s.oneOf) && s.oneOf.length > 0) return true;
if (Array.isArray(s.anyOf) && s.anyOf.length > 0) return true;
if (Array.isArray(s.allOf) && s.allOf.length > 0) return true;
if (s.type === 'object') return true;
if (s.type === 'array') {
// Multi-select-friendly arrays (string-enum + uniqueItems)
// get inline editing; everything else is complex.
const items = s.items || {};
const isMultiSelect = items.type === 'string'
&& Array.isArray(items.enum) && items.enum.length > 0
&& s.uniqueItems === true;
return !isMultiSelect;
}
return false;
}
function makeWidget(propSchema, col, initialValue) {
// Prefers explicit JSON Schema hints; falls back to column-spec
// hints (col.format / col.enum) for tables without a form.yaml;
// defaults to a plain text input.
const s = propSchema || {};
const colHint = col || {};
// Boolean → checkbox.
if (s.type === 'boolean') {
return widgetCheckbox(initialValue);
}
// Enum (string with explicit choices) → select dropdown.
const enumChoices = (Array.isArray(s.enum) && s.enum)
|| (Array.isArray(colHint.enum) && colHint.enum)
|| null;
if (enumChoices) {
return widgetSelect(enumChoices, initialValue);
}
// Multi-select (array of string-enum with uniqueItems).
if (s.type === 'array'
&& s.items && s.items.type === 'string'
&& Array.isArray(s.items.enum) && s.uniqueItems === true) {
return widgetMultiSelect(s.items.enum, initialValue);
}
// Number / integer → number input with min/max/step.
if (s.type === 'number' || s.type === 'integer'
|| colHint.format === 'number' || colHint.format === 'integer') {
return widgetNumber(s, initialValue);
}
// Date / date-time / email — typed inputs the browser can
// help validate.
const fmt = s.format || colHint.format;
if (fmt === 'date') return widgetTyped('date', initialValue);
if (fmt === 'date-time') return widgetTyped('datetime-local', initialValue);
if (fmt === 'email') return widgetTyped('email', initialValue);
// Long text → textarea (still inline; Phase 5 may add expand).
if (s.type === 'string' && Number(s.maxLength) > 200) {
return widgetTextarea(initialValue);
}
// Default: plain text input.
return widgetText(initialValue);
}
function widgetText(initial) {
const el = document.createElement('input');
el.type = 'text';
el.value = stringify(initial);
return {
element: el,
getValue: () => el.value,
focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} }
};
}
function widgetTextarea(initial) {
const el = document.createElement('textarea');
el.rows = 1;
el.value = stringify(initial);
return {
element: el,
getValue: () => el.value,
focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} }
};
}
function widgetTyped(htmlType, initial) {
const el = document.createElement('input');
el.type = htmlType;
el.value = stringify(initial);
return {
element: el,
getValue: () => el.value,
focus: () => el.focus()
};
}
function widgetNumber(s, initial) {
const el = document.createElement('input');
el.type = 'number';
if (s.minimum != null) el.min = String(s.minimum);
if (s.maximum != null) el.max = String(s.maximum);
if (s.type === 'integer') el.step = '1';
else if (s.multipleOf != null) el.step = String(s.multipleOf);
el.value = (initial == null || initial === '') ? '' : String(initial);
return {
element: el,
getValue: () => {
const v = el.value;
if (v === '') return null;
const n = Number(v);
return Number.isNaN(n) ? v : n;
},
focus: () => el.focus()
};
}
function widgetCheckbox(initial) {
const el = document.createElement('input');
el.type = 'checkbox';
el.checked = initial === true || initial === 'true';
return {
element: el,
getValue: () => el.checked,
focus: () => el.focus()
};
}
function widgetSelect(choices, initial) {
const el = document.createElement('select');
// Empty option lets the cell go back to "unset" without typing.
const empty = document.createElement('option');
empty.value = '';
empty.textContent = '—';
el.appendChild(empty);
for (let i = 0; i < choices.length; i++) {
const opt = document.createElement('option');
opt.value = String(choices[i]);
opt.textContent = String(choices[i]);
el.appendChild(opt);
}
el.value = initial == null ? '' : String(initial);
return {
element: el,
getValue: () => (el.value === '' ? null : el.value),
focus: () => el.focus()
};
}
function widgetMultiSelect(choices, initial) {
const el = document.createElement('select');
el.multiple = true;
el.size = Math.min(6, choices.length);
const initialSet = {};
const initArr = Array.isArray(initial) ? initial : [];
for (let i = 0; i < initArr.length; i++) initialSet[String(initArr[i])] = true;
for (let i = 0; i < choices.length; i++) {
const opt = document.createElement('option');
opt.value = String(choices[i]);
opt.textContent = String(choices[i]);
if (initialSet[opt.value]) opt.selected = true;
el.appendChild(opt);
}
return {
element: el,
getValue: () => {
const out = [];
for (let i = 0; i < el.options.length; i++) {
if (el.options[i].selected) out.push(el.options[i].value);
}
return out;
},
focus: () => el.focus()
};
}
function stringify(v) {
if (v == null) return '';
if (typeof v === 'object') {
try { return JSON.stringify(v); } catch (_) { return String(v); }
}
return String(v);
}
function sameValue(a, b) {
if (a === b) return true;
if (a == null && b == null) return true;
if (a == null || b == null) return false;
if (typeof a === 'object' || typeof b === 'object') {
try { return JSON.stringify(a) === JSON.stringify(b); }
catch (_) { return false; }
}
// Loose-string compare so number 42 == "42" from a text input.
return String(a) === String(b);
}
function navigateToRowForm(row) {
// Complex-type cells punt to the row's full form editor.
// The url field on each context row already points at
// <dir>/<id>.yaml.html — the form-mode re-edit URL.
if (!row || !row.url) return;
const nav = (window.tablesApp && window.tablesApp.navigateTo)
|| function (u) { window.location.assign(u); };
nav(row.url);
}
// --- Keyboard nav -------------------------------------------------
function moveSelection(dir) {
if (!app.state.selected) return;
let { row: r, col: c } = app.state.selected;
const total = rowCount();
const cols = colCount();
if (total === 0 || cols === 0) return;
switch (dir) {
case 'up': r = Math.max(0, r - 1); break;
case 'down': r = Math.min(total - 1, r + 1); break;
case 'left': c = Math.max(0, c - 1); break;
case 'right': c = Math.min(cols - 1, c + 1); break;
case 'home': c = 0; break;
case 'end': c = cols - 1; break;
case 'home-row': r = 0; c = 0; break;
case 'end-row': r = total - 1; c = cols - 1; break;
case 'left-wrap':
if (c > 0) { c--; }
else if (r > 0) { r--; c = cols - 1; }
break;
case 'right-wrap':
if (c < cols - 1) { c++; }
else if (r < total - 1) { r++; c = 0; }
break;
}
setSelected(r, c);
}
function onCellKey(ev) {
if (app.state.editing) return; // input owns its own keys
if (!app.state.selected) return;
const isRangeKey = ev.shiftKey;
switch (ev.key) {
case 'ArrowUp':
ev.preventDefault();
isRangeKey ? extendRange('up') : moveSelection('up');
return;
case 'ArrowDown':
ev.preventDefault();
isRangeKey ? extendRange('down') : moveSelection('down');
return;
case 'ArrowLeft':
ev.preventDefault();
isRangeKey ? extendRange('left') : moveSelection('left');
return;
case 'ArrowRight':
ev.preventDefault();
isRangeKey ? extendRange('right') : moveSelection('right');
return;
case 'Home':
ev.preventDefault();
if (ev.ctrlKey || ev.metaKey) moveSelection('home-row');
else moveSelection('home');
return;
case 'End':
ev.preventDefault();
if (ev.ctrlKey || ev.metaKey) moveSelection('end-row');
else moveSelection('end');
return;
case 'Tab':
ev.preventDefault();
moveSelection(ev.shiftKey ? 'left-wrap' : 'right-wrap');
return;
case 'Enter':
case 'F2':
ev.preventDefault();
enterEdit();
return;
case 'Escape':
ev.preventDefault();
clearSelection();
clearRange();
return;
case 'Delete':
case 'Backspace':
ev.preventDefault();
bulkClearSelection();
return;
case 'd':
case 'D':
if (ev.ctrlKey || ev.metaKey) {
ev.preventDefault();
bulkFill('down');
return;
}
break;
case 'r':
case 'R':
if (ev.ctrlKey || ev.metaKey) {
ev.preventDefault();
bulkFill('right');
return;
}
break;
}
if (isPrintableKey(ev)) {
// Replace value with the typed character (Excel convention).
ev.preventDefault();
enterEdit(ev.key);
}
}
// --- Range selection (multi-cell ops) -----------------------------
function extendRange(dir) {
if (!app.state.selected) return;
const range = ensureRange();
let { row: r, col: c } = range.focus;
const total = rowCount();
const cols = colCount();
switch (dir) {
case 'up': r = Math.max(0, r - 1); break;
case 'down': r = Math.min(total - 1, r + 1); break;
case 'left': c = Math.max(0, c - 1); break;
case 'right': c = Math.min(cols - 1, c + 1); break;
}
range.focus = { row: r, col: c };
applyRangeSelectionStyles(range);
}
function ensureRange() {
if (!app.state.range) {
const sel = app.state.selected;
app.state.range = {
anchor: { row: sel.row, col: sel.col },
focus: { row: sel.row, col: sel.col },
};
}
return app.state.range;
}
function clearRange() {
app.state.range = null;
const t = tableEl();
if (!t) return;
const all = t.querySelectorAll('[role="gridcell"]');
for (let i = 0; i < all.length; i++) {
all[i].classList.remove('zddc-table__cell--in-range');
}
}
function applyRangeSelectionStyles(range) {
const t = tableEl();
if (!t) return;
const all = t.querySelectorAll('[role="gridcell"]');
for (let i = 0; i < all.length; i++) {
all[i].classList.remove('zddc-table__cell--in-range');
}
const r0 = Math.min(range.anchor.row, range.focus.row);
const r1 = Math.max(range.anchor.row, range.focus.row);
const c0 = Math.min(range.anchor.col, range.focus.col);
const c1 = Math.max(range.anchor.col, range.focus.col);
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
const cell = cellAt(r, c);
if (cell) cell.classList.add('zddc-table__cell--in-range');
}
}
}
function rangeCells() {
// Returns an array of {rowIdx, colIdx, row, col} for every
// cell in the current range — or just the selected cell if
// no range is active. Skips cells whose row data can't be
// resolved (defensive).
const out = [];
const range = app.state.range;
if (range) {
const r0 = Math.min(range.anchor.row, range.focus.row);
const r1 = Math.max(range.anchor.row, range.focus.row);
const c0 = Math.min(range.anchor.col, range.focus.col);
const c1 = Math.max(range.anchor.col, range.focus.col);
for (let r = r0; r <= r1; r++) {
const row = rowDataAt(r);
if (!row) continue;
for (let c = c0; c <= c1; c++) {
const col = colAt(c);
if (col) out.push({ rowIdx: r, colIdx: c, row: row, col: col });
}
}
return out;
}
if (!app.state.selected) return out;
const { row: r, col: c } = app.state.selected;
const row = rowDataAt(r);
const col = colAt(c);
if (row && col) out.push({ rowIdx: r, colIdx: c, row: row, col: col });
return out;
}
function bulkClearSelection() {
// Delete / Backspace in nav mode: clear every selected cell.
// Pushes one undo Command spanning all affected cells.
const cells = rangeCells();
if (cells.length === 0) return;
const undoCells = [];
for (let i = 0; i < cells.length; i++) {
const c = cells[i];
const oldRaw = app.modules.util.resolveField(c.row.data, c.col.field);
const priorDraft = getDraft(rowKey(c.row), c.col.field);
const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw;
setDraft(rowKey(c.row), c.col.field, null);
undoCells.push({
rowId: rowKey(c.row),
field: c.col.field,
oldValue: undoOld,
newValue: null,
});
}
const undoMod = app.modules.undo;
if (undoMod) undoMod.push({ cells: undoCells });
if (typeof app.repaint === 'function') app.repaint();
}
function bulkFill(dir) {
// Ctrl+D fills the top row's values down through the range.
// Ctrl+R fills the left column's values right through the range.
// No-op when no range is active (Excel does the same).
const range = app.state.range;
if (!range) return;
const r0 = Math.min(range.anchor.row, range.focus.row);
const r1 = Math.max(range.anchor.row, range.focus.row);
const c0 = Math.min(range.anchor.col, range.focus.col);
const c1 = Math.max(range.anchor.col, range.focus.col);
const undoCells = [];
for (let r = r0; r <= r1; r++) {
const row = rowDataAt(r);
if (!row) continue;
for (let c = c0; c <= c1; c++) {
const col = colAt(c);
if (!col) continue;
const srcR = (dir === 'down') ? r0 : r;
const srcC = (dir === 'right') ? c0 : c;
if (r === srcR && c === srcC) continue;
const srcRow = rowDataAt(srcR);
const srcCol = colAt(srcC);
if (!srcRow || !srcCol) continue;
const value = effectiveCellValue(srcRow, srcCol);
const oldRaw = app.modules.util.resolveField(row.data, col.field);
const priorDraft = getDraft(rowKey(row), col.field);
const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw;
setDraft(rowKey(row), col.field, value);
undoCells.push({
rowId: rowKey(row),
field: col.field,
oldValue: undoOld,
newValue: value,
});
}
}
if (undoCells.length > 0) {
const undoMod = app.modules.undo;
if (undoMod) undoMod.push({ cells: undoCells });
if (typeof app.repaint === 'function') app.repaint();
}
}
// --- Wiring -------------------------------------------------------
function attachToTable() {
const t = tableEl();
if (!t) return;
t.setAttribute('role', 'grid');
t.addEventListener('keydown', onCellKey);
}
function attachToRow(tr, rowId) {
tr.setAttribute('role', 'row');
tr.setAttribute('data-row-id', rowId);
}
function attachToCell(td, rowIdx, colIdx) {
td.setAttribute('role', 'gridcell');
td.setAttribute('data-col-idx', String(colIdx));
td.setAttribute('data-row-idx', String(rowIdx));
td.setAttribute('tabindex', '-1');
td.addEventListener('click', function (ev) {
ev.stopPropagation();
if (ev.shiftKey && app.state.selected) {
// Shift+click extends the range from the existing
// anchor to the clicked cell.
const range = ensureRange();
range.focus = { row: rowIdx, col: colIdx };
applyRangeSelectionStyles(range);
// Move tabindex/focus marker to the clicked cell but
// keep the anchor in place.
setSelected(rowIdx, colIdx, { keepRange: true });
} else {
clearRange();
setSelected(rowIdx, colIdx);
}
});
td.addEventListener('dblclick', function (ev) {
ev.stopPropagation();
clearRange();
setSelected(rowIdx, colIdx, { noFocus: true });
enterEdit();
});
}
app.modules.editor = {
attachToTable: attachToTable,
attachToRow: attachToRow,
attachToCell: attachToCell,
setSelected: setSelected,
clearSelection: clearSelection,
moveSelection: moveSelection,
enterEdit: enterEdit,
rowKey: rowKey,
getDraft: getDraft,
setDraft: setDraft,
clearDraftField: clearDraftField,
effectiveCellValue: effectiveCellValue
};
})(window.tablesApp);
// undo.js — Phase 5 of editable-cell mode.
//
// Linear command stack, depth 50, session-local. Every successful
// per-cell edit and every bulk operation (paste, fill, delete) push
// a Command onto the stack. Ctrl/Cmd+Z pops the most recent and
// replays the inverse — sets each affected cell's draft buffer
// back to its `oldValue` (or clears the draft when oldValue was
// the row's stored value), then triggers a re-paint and the
// row-blur save flow picks the change up like any other edit.
//
// Why local-only: shared undo across multiple users is conceptually
// broken under last-writer-wins (undoing my edit might revert
// someone else's intervening edit). Every production grid keeps
// undo per-tab; we follow.
//
// Why no redo: minimum viable. Adding redo is a parallel forward
// stack cleared on any new edit. Cheap to add later if users miss
// it.
//
// Command shape:
// { cells: [ {rowId, field, oldValue, newValue}, ... ] }
//
// One-cell edits push a single-cell Command. Bulk operations push
// one Command with N cells so a single Ctrl+Z reverts the whole
// group.
(function (app) {
'use strict';
const STACK_MAX = 50;
const _stack = [];
function push(cmd) {
if (!cmd || !cmd.cells || cmd.cells.length === 0) return;
_stack.push(cmd);
if (_stack.length > STACK_MAX) {
_stack.shift();
}
}
function depth() { return _stack.length; }
function clear() { _stack.length = 0; }
function undo() {
const cmd = _stack.pop();
if (!cmd || !cmd.cells || cmd.cells.length === 0) return null;
const editor = app.modules.editor;
if (!editor) return null;
for (let i = 0; i < cmd.cells.length; i++) {
const c = cmd.cells[i];
// Compare oldValue to the row's stored data — if they
// match, clear the draft (the user's edit is being
// reversed back to baseline). Otherwise set draft = old.
const row = findRow(c.rowId);
if (!row) continue;
const stored = app.modules.util.resolveField(row.data, c.field);
if (sameValue(stored, c.oldValue)) {
editor.clearDraftField(c.rowId, c.field);
} else {
editor.setDraft(c.rowId, c.field, c.oldValue);
}
}
if (typeof app.repaint === 'function') app.repaint();
return cmd;
}
function findRow(rowId) {
const editor = app.modules.editor;
const all = (app.state && app.state.rows) || [];
for (let i = 0; i < all.length; i++) {
if (editor.rowKey(all[i]) === rowId) return all[i];
}
return null;
}
function sameValue(a, b) {
if (a === b) return true;
if (a == null && b == null) return true;
if (a == null || b == null) return false;
if (typeof a === 'object' || typeof b === 'object') {
try { return JSON.stringify(a) === JSON.stringify(b); }
catch (_) { return false; }
}
return String(a) === String(b);
}
// Hotkey: Ctrl+Z (Cmd+Z on macOS). Bound at the document level
// so the user can undo from anywhere on the page, not just from
// within a focused cell.
function onKey(ev) {
const isMod = ev.ctrlKey || ev.metaKey;
if (!isMod) return;
if (ev.key === 'z' || ev.key === 'Z') {
// Skip when the active element is a text-input-like; we
// don't want to override the browser's intra-input undo.
const ae = document.activeElement;
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) {
return;
}
ev.preventDefault();
undo();
}
}
document.addEventListener('keydown', onKey);
app.modules.undo = {
push: push,
undo: undo,
depth: depth,
clear: clear,
};
})(window.tablesApp);
// save.js — Phase 3 of editable-cell mode.
//
// Row-level batch save on row-blur. While the user is editing cells
// inside a row, draft values accumulate in app.state.drafts. When the
// editor's selection moves to a different row (or focus leaves the
// grid entirely), this module fires one PUT for the row that lost
// focus, with full merged data + If-Match for the row's tracked ETag.
//
// Three response paths:
//
// - 200 / 201 / 202: success or queued-offline (cache outbox).
// Drafts clear, row.data merges, new ETag captured. Row's
// "dirty" indicator drops.
//
// - 412 Precondition Failed: someone else changed this row since
// we read it. Drafts STAY — never silently discard the user's
// typing. Row gets a "stale" badge with [Use mine] / [Reload]
// in the page status bar. "Use mine" re-GETs the row to pick up
// the new ETag and server data, replays drafts on top, re-PUTs
// (this is the client-side field-level LWW trick from the
// architecture report — fields the user didn't touch get the
// server's new values automatically). "Reload" drops drafts and
// refreshes from server.
//
// - 422 Unprocessable Entity: server-side schema validation failed.
// Body is {errors: [{path, message}, ...]}. Each path → field,
// marked with a red corner on the cell. Drafts stay so the user
// can correct in place.
//
// - Other (4xx / 5xx / network): row marked errored with the
// status code; drafts stay.
//
// Outbox transparency: when running through a downstream client, the
// PUT is intercepted by the cache layer; on local network failure
// it's queued and the response is 202 Accepted with X-ZDDC-Cache:
// queued. We treat 202 as success-ish — drafts clear, indicator
// shows a small "queued" badge so the user knows the write hasn't
// reached upstream yet.
(function (app) {
'use strict';
function modules() {
return app.modules.editor;
}
function findRowById(rowId) {
const all = (app.state && app.state.rows) || [];
for (let i = 0; i < all.length; i++) {
if (modules().rowKey(all[i]) === rowId) return all[i];
}
return null;
}
function mergeRow(data, drafts) {
// Shallow merge: drafts are field-level overrides on the row's
// top-level data object. Phase 2's complex-type cells punt to
// form-mode and never produce drafts here, so drafts only
// contain primitive / string-array values that are safe to
// overwrite the corresponding top-level field.
return Object.assign({}, data || {}, drafts || {});
}
function rowFromState(rowId) {
return {
row: findRowById(rowId),
drafts: (app.state.drafts && app.state.drafts[rowId]) || null,
};
}
// --- Visual state markers ----------------------------------------
function setRowState(rowId, stateName) {
// Apply a CSS state class to the row matching rowId. States:
// "" / null — no marker
// "dirty" — has uncommitted drafts
// "saving" — PUT in flight
// "stale" — server returned 412
// "errored" — server returned 4xx/5xx other than 412/422
// "queued" — write went into the outbox
// "invalid" — server returned 422
const tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
if (!tr) return;
const stateClasses = ['dirty', 'saving', 'stale', 'errored', 'queued', 'invalid'];
for (let i = 0; i < stateClasses.length; i++) {
tr.classList.remove('zddc-table__row--' + stateClasses[i]);
}
if (stateName) tr.classList.add('zddc-table__row--' + stateName);
}
function markCellInvalid(rowId, field, message) {
const tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
if (!tr) return;
// Walk the column list to find the field's column index;
// data-col-idx is the numeric position rendered into each td.
const cols = (app.context && app.context.columns) || [];
let idx = -1;
for (let i = 0; i < cols.length; i++) {
if (cols[i].field === field) { idx = i; break; }
}
if (idx < 0) return;
const target = tr.querySelector('[role="gridcell"][data-col-idx="' + idx + '"]');
if (!target) return;
target.classList.add('zddc-table__cell--invalid');
if (message) target.setAttribute('title', message);
}
function clearCellInvalid(rowId) {
const tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
if (!tr) return;
const invalids = tr.querySelectorAll('.zddc-table__cell--invalid');
for (let i = 0; i < invalids.length; i++) {
invalids[i].classList.remove('zddc-table__cell--invalid');
invalids[i].removeAttribute('title');
}
}
function cssEscape(s) {
// CSS.escape if available; otherwise a defensive escape for
// the characters that appear in URL paths used as data-row-id
// values. Browsers everywhere modern enough to support the
// FS Access API have CSS.escape, so this is mostly defensive.
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(s);
return String(s).replace(/[^a-zA-Z0-9_-]/g, function (ch) {
return '\\' + ch;
});
}
// --- Status bar (stale-row prompt) --------------------------------
function showStatusPrompt(rowId, message, actions) {
// Renders into #table-status (hidden by default per template).
// actions = [{label, onClick}, ...]
const el = document.getElementById('table-status');
if (!el) return;
el.textContent = '';
el.classList.add('table-status--prompt');
const span = document.createElement('span');
span.textContent = message;
el.appendChild(span);
for (let i = 0; i < (actions || []).length; i++) {
const a = actions[i];
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-secondary btn-sm';
btn.textContent = a.label;
btn.addEventListener('click', a.onClick);
el.appendChild(btn);
}
const dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'btn btn-secondary btn-sm';
dismiss.textContent = '×';
dismiss.title = 'Dismiss';
dismiss.addEventListener('click', clearStatus);
el.appendChild(dismiss);
el.hidden = false;
el.setAttribute('data-row-id', rowId);
}
function clearStatus() {
const el = document.getElementById('table-status');
if (!el) return;
el.textContent = '';
el.hidden = true;
el.removeAttribute('data-row-id');
el.classList.remove('table-status--prompt');
}
// --- The save itself ---------------------------------------------
async function saveRow(rowId, opts) {
opts = opts || {};
const { row, drafts } = rowFromState(rowId);
if (!row || !drafts || Object.keys(drafts).length === 0) {
return { status: 'noop' };
}
if (!row.yamlUrl) {
// file:// mode or rows from inline-context test fixtures
// don't have a URL to PUT to — bail silently.
return { status: 'no-url' };
}
if (row.editable === false) {
// Row is read-only per the server. Don't even try.
return { status: 'readonly' };
}
setRowState(rowId, 'saving');
const merged = mergeRow(row.data, drafts);
const yamlBody = window.jsyaml.dump(merged);
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
const fetchOpts = {
method: 'PUT',
body: yamlBody,
headers: headers,
credentials: 'same-origin',
};
// The unload path passes keepalive:true so the PUT outlives the
// page navigation. Subject to the spec's 64 KB body cap — large
// rows may fail in that path; normal saves are unaffected.
if (opts.keepalive) fetchOpts.keepalive = true;
let resp;
try {
resp = await fetch(row.yamlUrl, fetchOpts);
} catch (err) {
// Network failure — outbox-fronted client should still
// resolve with 202; reaching here means a hard client-side
// network error. Mark errored, drafts stay.
console.error('[tables] save network error', err);
setRowState(rowId, 'errored');
return { status: 'network-error', error: err };
}
if (resp.status === 200 || resp.status === 201) {
// Success: clear drafts + invalid marks, capture new ETag.
const newEtag = resp.headers.get('ETag');
if (newEtag) row.etag = newEtag.replace(/"/g, '');
row.data = merged;
delete app.state.drafts[rowId];
clearCellInvalid(rowId);
setRowState(rowId, '');
// If a status prompt was up for this row, drop it.
const sb = document.getElementById('table-status');
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
return { status: 'ok' };
}
if (resp.status === 202) {
// Outbox queued. Drafts clear (they're persisted in the
// outbox; the server will replay them on reconnect), but
// the row stays marked queued so the user knows.
row.data = merged;
delete app.state.drafts[rowId];
setRowState(rowId, 'queued');
return { status: 'queued' };
}
if (resp.status === 412) {
// Precondition Failed — someone else changed the row.
// Drafts STAY. Surface the prompt.
setRowState(rowId, 'stale');
showStatusPrompt(
rowId,
'This row was changed by someone else. ',
[
{ label: 'Use mine', onClick: () => useMine(rowId) },
{ label: 'Reload', onClick: () => reload(rowId) },
]
);
return { status: 'conflict' };
}
if (resp.status === 422) {
// Validation errors. Body shape matches the form system's
// 422 response: {errors: [{path: "/field", message}, ...]}.
let body = {};
try { body = await resp.json(); } catch (_) { /* ignore */ }
clearCellInvalid(rowId);
const errs = body.errors || [];
for (let i = 0; i < errs.length; i++) {
const e = errs[i];
const field = String(e.path || '').replace(/^\//, '').split('/')[0];
if (field) markCellInvalid(rowId, field, e.message || 'invalid');
}
setRowState(rowId, 'invalid');
return { status: 'invalid', errors: errs };
}
// Other status — generic error.
console.warn('[tables] save returned', resp.status);
setRowState(rowId, 'errored');
return { status: 'http-error', code: resp.status };
}
async function useMine(rowId) {
const { row, drafts } = rowFromState(rowId);
if (!row || !drafts) return;
// Re-GET the row to learn the latest server state + ETag.
try {
const resp = await fetch(row.yamlUrl, { credentials: 'same-origin' });
if (!resp.ok) {
console.warn('[tables] reload on conflict failed', resp.status);
return;
}
const text = await resp.text();
const fresh = window.jsyaml.load(text) || {};
row.data = fresh;
const newEtag = resp.headers.get('ETag');
row.etag = newEtag ? newEtag.replace(/"/g, '') : null;
} catch (err) {
console.error('[tables] reload on conflict error', err);
return;
}
// Drafts preserved — replay against the new base.
return saveRow(rowId);
}
async function reload(rowId) {
const row = findRowById(rowId);
if (!row) return;
try {
const resp = await fetch(row.yamlUrl, { credentials: 'same-origin' });
if (!resp.ok) return;
const text = await resp.text();
row.data = window.jsyaml.load(text) || {};
const newEtag = resp.headers.get('ETag');
row.etag = newEtag ? newEtag.replace(/"/g, '') : null;
} catch (_) { return; }
delete app.state.drafts[rowId];
clearCellInvalid(rowId);
setRowState(rowId, '');
clearStatus();
// Trigger a re-paint via the public app callback if one exists.
if (typeof app.repaint === 'function') app.repaint();
}
// --- Trigger: row-blur ------------------------------------------
let _previousSelectedRowId = null;
function trackSelectionChange(prevRowId, nextRowId) {
// Fires when the editor's selection changes rows. If prevRow
// had drafts, save it now. nextRow can be null (focus left
// the grid) — also a save trigger.
if (prevRowId && prevRowId !== nextRowId) {
const drafts = app.state.drafts && app.state.drafts[prevRowId];
if (drafts && Object.keys(drafts).length > 0) {
// Fire and forget. The user has moved on; we don't
// want to block their flow waiting for the server.
saveRow(prevRowId).catch(err => {
console.error('[tables] saveRow rejection', err);
});
}
}
}
function onSelectionChanged(selected) {
const prevRowId = _previousSelectedRowId;
const nextRowId = selected ? rowIdAtIndex(selected.row) : null;
if (prevRowId !== nextRowId) {
trackSelectionChange(prevRowId, nextRowId);
_previousSelectedRowId = nextRowId;
}
// Mark dirty rows visually whenever selection settles.
markAllDirtyRows();
}
function rowIdAtIndex(visibleRowIdx) {
const tr = document.querySelectorAll('#table-root tbody > tr')[visibleRowIdx];
return tr ? tr.getAttribute('data-row-id') : null;
}
function markAllDirtyRows() {
// After a re-paint or selection change, re-apply dirty state
// to any row that has drafts (CSS classes don't survive
// tbody.innerHTML='' in renderBody).
const drafts = app.state.drafts || {};
const tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
const trs = tbody.querySelectorAll('tr');
for (let i = 0; i < trs.length; i++) {
const tr = trs[i];
const rowId = tr.getAttribute('data-row-id');
if (rowId && drafts[rowId] && Object.keys(drafts[rowId]).length > 0) {
if (!tr.classList.contains('zddc-table__row--saving') &&
!tr.classList.contains('zddc-table__row--stale') &&
!tr.classList.contains('zddc-table__row--invalid') &&
!tr.classList.contains('zddc-table__row--errored') &&
!tr.classList.contains('zddc-table__row--queued')) {
tr.classList.add('zddc-table__row--dirty');
}
}
}
}
function flushAllDrafts() {
// Page-unload safety net. Best-effort: any row with drafts
// gets one final save attempt. fetch() is async, the page may
// already be navigating; we just kick the requests off.
const drafts = app.state.drafts || {};
const ids = Object.keys(drafts);
for (let i = 0; i < ids.length; i++) {
saveRow(ids[i], { keepalive: true }).catch(() => {});
}
}
// Window unload handler — call any in-flight drafts so the user
// doesn't lose typing on tab-close. The PUT uses keepalive:true so
// it survives navigation; that comes with a 64 KB body cap.
window.addEventListener('beforeunload', function (_ev) {
flushAllDrafts();
});
app.modules.save = {
saveRow: saveRow,
useMine: useMine,
reload: reload,
onSelectionChanged: onSelectionChanged,
markAllDirtyRows: markAllDirtyRows,
flushAllDrafts: flushAllDrafts,
};
})(window.tablesApp);
// clipboard.js — Phase 4 of editable-cell mode.
//
// Bidirectional clipboard interop with Excel / Google Sheets / any
// other spreadsheet that uses RFC-4180-ish TSV on the text/plain
// clipboard mime.
//
// Copy: when a single cell is selected, Ctrl/Cmd+C writes that
// cell's value as plain text. Range selection (Phase 5) extends
// this to a TSV rectangle.
//
// Paste: Ctrl/Cmd+V on the focused cell parses text/plain as TSV
// (tabs between columns, newlines between rows; embedded newlines
// or tabs are quoted with double-quotes; doubled "" escapes).
//
// - 1×1 clipboard into selected cell → writes that one cell.
// - N×M clipboard into selected cell → SPILLS from the anchor
// cell down/right to (anchor.row + N - 1, anchor.col + M - 1).
// Out-of-bounds cells are silently dropped (Excel convention).
//
// Each pasted cell goes through the same draft-buffer write path
// as a normal edit — the row-blur save trigger picks them up,
// and the per-cell schema-driven coercion (Phase 2) applies.
// Per-cell validation runs on the next save attempt; invalid
// cells get the red-corner mark.
(function (app) {
'use strict';
function editor() { return app.modules.editor; }
// --- TSV parsing --------------------------------------------------
// parseTSV(text) → string[][]. Honors RFC-4180-ish quoting:
// - A field surrounded by " can contain tabs, newlines, and
// literal " characters escaped as "".
// - An unquoted field ends at the next tab, newline, or end.
// - Bare \r is treated as part of \r\n (Windows line endings).
function parseTSV(text) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
const s = String(text == null ? '' : text);
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (inQuotes) {
if (ch === '"') {
if (s[i + 1] === '"') {
// Escaped quote inside a quoted field.
field += '"';
i++;
} else {
// End of quoted field.
inQuotes = false;
}
} else {
field += ch;
}
continue;
}
if (ch === '"' && field === '') {
// Open quote — only at start of field.
inQuotes = true;
continue;
}
if (ch === '\t') {
row.push(field);
field = '';
continue;
}
if (ch === '\n' || ch === '\r') {
// \r\n — consume the \n too.
if (ch === '\r' && s[i + 1] === '\n') i++;
row.push(field);
field = '';
rows.push(row);
row = [];
continue;
}
field += ch;
}
// Trailing field (no terminator).
if (field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
// Excel often appends a trailing empty row from the final \n;
// drop one trailing all-empty row to match that convention.
if (rows.length > 0) {
const last = rows[rows.length - 1];
if (last.length === 1 && last[0] === '') rows.pop();
}
return rows;
}
// formatTSV(grid) → string. Reverse of parseTSV. Quotes any
// field containing tab, newline, or double-quote.
function formatTSV(grid) {
const lines = [];
for (let r = 0; r < grid.length; r++) {
const row = grid[r];
const cells = [];
for (let c = 0; c < row.length; c++) {
cells.push(formatCell(row[c]));
}
lines.push(cells.join('\t'));
}
return lines.join('\n');
}
function formatCell(v) {
const s = (v == null) ? '' : String(v);
if (/[\t\n\r"]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
// --- Apply paste --------------------------------------------------
function applyPaste(anchorRowIdx, anchorColIdx, grid) {
// grid is string[][]. Returns {applied: int, skipped: int}.
const ed = editor();
const totalRows = visibleRowCount();
const cols = (app.context && app.context.columns) || [];
const totalCols = cols.length;
let applied = 0, skipped = 0;
for (let r = 0; r < grid.length; r++) {
const dstR = anchorRowIdx + r;
if (dstR >= totalRows) { skipped += grid[r].length; continue; }
const row = rowDataAtIndex(dstR);
if (!row) { skipped += grid[r].length; continue; }
for (let c = 0; c < grid[r].length; c++) {
const dstC = anchorColIdx + c;
if (dstC >= totalCols) { skipped++; continue; }
const col = cols[dstC];
if (!col) { skipped++; continue; }
const newValue = coerceCell(grid[r][c], col, row);
ed.setDraft(ed.rowKey(row), col.field, newValue);
applied++;
}
}
return { applied: applied, skipped: skipped };
}
function visibleRowCount() {
return document.querySelectorAll('#table-root tbody > tr').length;
}
function rowDataAtIndex(r) {
const tr = document.querySelectorAll('#table-root tbody > tr')[r];
if (!tr) return null;
const rowId = tr.getAttribute('data-row-id');
if (rowId == null) return null;
const all = (app.state && app.state.rows) || [];
for (let i = 0; i < all.length; i++) {
if (editor().rowKey(all[i]) === rowId) return all[i];
}
return null;
}
function coerceCell(raw, col, _row) {
// Phase 2's editor coerces values typed into a number/checkbox/
// select widget. Pasted cells arrive as raw strings; coerce
// here so the draft holds the right JS type. Falls back to the
// raw string when coercion is ambiguous.
const fmt = col.format;
if (fmt === 'number' || fmt === 'integer' || isNumericSchema(col)) {
const n = Number(raw);
if (raw.trim() !== '' && !Number.isNaN(n)) return n;
}
if (isBooleanSchema(col)) {
const t = String(raw).trim().toLowerCase();
if (t === 'true' || t === 'yes' || t === '1') return true;
if (t === 'false' || t === 'no' || t === '0' || t === '') return false;
}
return raw;
}
function isNumericSchema(col) {
const s = propSchema(col);
return !!(s && (s.type === 'number' || s.type === 'integer'));
}
function isBooleanSchema(col) {
const s = propSchema(col);
return !!(s && s.type === 'boolean');
}
function propSchema(col) {
const ctx = app.context || {};
if (!ctx.rowSchema || !ctx.rowSchema.properties) return null;
return ctx.rowSchema.properties[col.field] || null;
}
// --- Event handlers ----------------------------------------------
function onPaste(ev) {
if (!app.state || !app.state.selected) return;
if (app.state.editing) return; // input owns its own paste
const text = ev.clipboardData && ev.clipboardData.getData('text/plain');
if (!text) return;
ev.preventDefault();
const grid = parseTSV(text);
if (!grid.length) return;
const { row: r, col: c } = app.state.selected;
const result = applyPaste(r, c, grid);
// Trigger a re-paint so draft values display.
if (typeof app.repaint === 'function') app.repaint();
if (result.skipped > 0) {
notifyToast(
'Pasted ' + result.applied + ' cell' + plural(result.applied) +
'; ' + result.skipped + ' dropped (out of bounds)'
);
}
}
function onCopy(ev) {
if (!app.state || !app.state.selected) return;
if (app.state.editing) return; // input owns its own copy
const { row: r, col: c } = app.state.selected;
const row = rowDataAtIndex(r);
const cols = (app.context && app.context.columns) || [];
const col = cols[c];
if (!row || !col) return;
const value = editor().effectiveCellValue(row, col);
ev.preventDefault();
if (ev.clipboardData) {
ev.clipboardData.setData('text/plain', formatCell(value));
}
}
function plural(n) { return n === 1 ? '' : 's'; }
function notifyToast(msg) {
// Cheap toast: write to #table-status, auto-clear after 4s.
// Coexists with save.js's stale-row prompt — just don't fire
// if a prompt is currently up.
const el = document.getElementById('table-status');
if (!el) return;
if (el.classList.contains('table-status--prompt')) return;
el.textContent = msg;
el.hidden = false;
clearTimeout(notifyToast._t);
notifyToast._t = setTimeout(() => {
if (el.textContent === msg) {
el.hidden = true;
el.textContent = '';
}
}, 4000);
}
function attach() {
// Listen at the document level so paste events bubble from
// any cell with focus. No element-specific binding because
// Phase 1's roving tabindex moves focus around.
document.addEventListener('paste', onPaste);
document.addEventListener('copy', onCopy);
}
// Auto-wire on bootstrap. table-mode only — the dispatcher hides
// form-mode in this bundle, but be defensive if both modes ever
// coexist on a page (test fixtures): attach unconditionally; the
// handler bails when there's no selected cell.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', attach, { once: true });
} else {
attach();
}
app.modules.clipboard = {
parseTSV: parseTSV,
formatTSV: formatTSV,
applyPaste: applyPaste,
};
})(window.tablesApp);
(function (app) {
'use strict';
function renderHeader(theadEl, columns, sortState, filterMap, onHeaderClick, onFilterChange) {
const util = app.modules.util;
const filters = app.modules.filters;
const sort = app.modules.sort;
theadEl.innerHTML = '';
const titleRow = util.h('tr', { className: 'zddc-table__title-row' });
const filterRow = util.h('tr', { className: 'zddc-table__filter-row' });
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const indicator = sort.indicator(sortState, col.field);
const th = util.h('th', {
className: 'zddc-table__th',
'data-field': col.field,
style: col.width ? 'width:' + col.width : null,
onClick: function (ev) { onHeaderClick(col.field, ev.shiftKey); }
}, col.title || col.field, indicator);
titleRow.appendChild(th);
const td = util.h('td', { className: 'zddc-table__filter-cell' });
// Every column gets the same text-contains filter input, even
// enum columns — keeps the filter row visually uniform and
// doesn't constrain users to picking from the enum (a
// case-insensitive substring match works for both free-text
// and enum data).
const f = filterMap[col.field] || filters.defaultFilterFor(col);
const input = util.h('input', {
type: 'text',
className: 'zddc-table__filter-text',
placeholder: 'filter…',
'aria-label': 'Filter ' + (col.title || col.field),
value: typeof f.value === 'string' ? f.value : '',
onInput: function (ev) {
onFilterChange(col.field, { kind: 'contains', value: ev.target.value });
}
});
td.appendChild(input);
filterRow.appendChild(td);
}
theadEl.appendChild(titleRow);
theadEl.appendChild(filterRow);
}
function renderBody(tbodyEl, rows, columns) {
const util = app.modules.util;
const editor = app.modules.editor;
tbodyEl.innerHTML = '';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const tr = util.h('tr', {
className: 'zddc-table__row' + (row.editable ? ' zddc-table__row--editable' : ' zddc-table__row--readonly'),
'data-url': row.url,
'data-editable': row.editable ? '1' : '0'
});
const rowId = editor ? editor.rowKey(row) : (row.url || '');
if (editor) {
editor.attachToRow(tr, rowId);
}
for (let c = 0; c < columns.length; c++) {
const col = columns[c];
// Editor's draft buffer overrides the row's stored value
// until Phase 3 commits it. Falls back to row.data when
// no draft is present.
const value = editor
? editor.effectiveCellValue(row, col)
: util.resolveField(row.data, col.field);
const text = util.formatCell(value, col.format);
const td = util.h('td', { className: 'zddc-table__cell' }, text);
if (editor) {
editor.attachToCell(td, i, c);
}
tr.appendChild(td);
}
tbodyEl.appendChild(tr);
}
}
function renderRowCount(el, displayed, total) {
if (!el) return;
if (displayed === total) {
el.textContent = total + (total === 1 ? ' row' : ' rows');
} else {
el.textContent = displayed + ' of ' + total + ' rows';
}
}
app.modules.render = {
header: renderHeader,
body: renderBody,
rowCount: renderRowCount
};
})(window.tablesApp);
(function (app) {
'use strict';
async function init() {
// Both apps (table + form) ship in the same bundle. Skip if
// mode dispatcher said this isn't our mode — form-mode requests
// are handled by formApp.
if (window.zddcMode === 'form') {
return;
}
const ctx = await app.modules.context.load();
app.context = ctx;
const titleEl = document.getElementById('table-title');
if (ctx.title && titleEl) {
titleEl.textContent = ctx.title;
document.title = 'ZDDC — ' + ctx.title;
}
const descEl = document.getElementById('table-description');
if (descEl && ctx.description) {
descEl.textContent = ctx.description;
descEl.hidden = false;
}
const tableEl = document.getElementById('table-root');
const theadEl = tableEl.querySelector('thead');
const tbodyEl = tableEl.querySelector('tbody');
const emptyEl = document.getElementById('table-empty');
const countEl = document.getElementById('table-rowcount');
const clearBtn = document.getElementById('table-clear-filters');
const addRowBtn = document.getElementById('table-add-row');
// Add-row button: link to <name>.form.html, the form-system's
// empty-form URL for this table's row schema. POST creates a
// new submission and the server redirects to the row's edit
// URL. Hidden when we can't derive a table name from the
// pathname (e.g. inline-context test harness opening tables.html
// directly without a *.table.html URL).
if (addRowBtn) {
// Page is at <dir>/table.html; the row-creation form is at
// <dir>/form.html — same directory, just swap the basename.
if (/\/table\.html$/.test(location.pathname || '')) {
addRowBtn.href = 'form.html';
addRowBtn.hidden = false;
}
}
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
const state = app.state;
state.rows = allRows;
state.sort = app.modules.sort.defaultsFromContext(ctx);
state.filter = {};
// Seed default filters from context.defaults.filter (per-column).
if (ctx.defaults && ctx.defaults.filter && typeof ctx.defaults.filter === 'object') {
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const seeded = ctx.defaults.filter[col.field];
if (seeded == null) {
continue;
}
// Filter UI is uniformly text-contains. If the spec
// seeds an array (legacy enum-style), coerce to a
// comma-joined contains string — partial match on any
// listed value still narrows the table sensibly.
const seedStr = Array.isArray(seeded) ? seeded.join(',') : String(seeded);
state.filter[col.field] = { kind: 'contains', value: seedStr };
}
}
function anyFilterActive() {
const filters = app.modules.filters;
const keys = Object.keys(state.filter);
for (let i = 0; i < keys.length; i++) {
if (!filters.isEmpty(state.filter[keys[i]])) {
return true;
}
}
return false;
}
function paint() {
const filtered = app.modules.filters.apply(state.rows, columns, state.filter, app.modules.util.resolveField);
const sorted = app.modules.sort.apply(filtered, state.sort, columns, app.modules.util);
app.modules.render.header(theadEl, columns, state.sort, state.filter, onHeaderClick, onFilterChange);
app.modules.render.body(tbodyEl, sorted, columns);
app.modules.render.rowCount(countEl, sorted.length, state.rows.length);
if (emptyEl) {
emptyEl.hidden = sorted.length > 0 || state.rows.length === 0;
}
if (clearBtn) {
clearBtn.hidden = !anyFilterActive();
}
// Restore the editor's selection across re-paints so a sort
// or filter change doesn't dump the user out of the cell
// they were on. Selected coords clamp to the new bounds in
// setSelected; if the row vanished (filter excluded it),
// we land on the last valid cell instead of clearing.
const editor = app.modules.editor;
if (editor) {
editor.attachToTable();
if (state.selected) {
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
}
}
// Re-apply Phase-3 dirty-row markers — tbody.innerHTML='' in
// renderBody wiped them.
const save = app.modules.save;
if (save && typeof save.markAllDirtyRows === 'function') {
save.markAllDirtyRows();
}
}
// Public re-paint entry point so other modules (save.useMine /
// save.reload) can request a refresh after they mutate row state.
app.repaint = paint;
function onHeaderClick(field, shiftKey) {
state.sort = app.modules.sort.cycle(state.sort, field, shiftKey);
paint();
}
function onFilterChange(field, value) {
state.filter[field] = value;
paint();
}
if (clearBtn) {
clearBtn.addEventListener('click', function () {
state.filter = {};
paint();
});
}
paint();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(window.tablesApp);
(function (global) {
'use strict';
if (global.formApp) {
return;
}
global.formApp = {
context: null,
rootWidget: null,
modules: {}
};
})(window);
(function (app) {
'use strict';
function load() {
const el = document.getElementById('form-context');
if (!el) {
return {};
}
try {
return JSON.parse(el.textContent || '{}');
} catch (err) {
console.error('[form] failed to parse #form-context', err);
return {};
}
}
app.modules.context = { load };
})(window.formApp);
(function (app) {
'use strict';
const util = {};
util.h = function (tag, attrs) {
const el = document.createElement(tag);
if (attrs) {
for (const k of Object.keys(attrs)) {
const v = attrs[k];
if (v == null || v === false) {
continue;
}
if (k === 'className') {
el.className = v;
} else if (k.length > 2 && k.slice(0, 2) === 'on' && typeof v === 'function') {
el.addEventListener(k.slice(2).toLowerCase(), v);
} else if (v === true) {
el.setAttribute(k, '');
} else {
el.setAttribute(k, v);
}
}
}
for (let i = 2; i < arguments.length; i++) {
const c = arguments[i];
if (c == null || c === false) {
continue;
}
if (typeof c === 'string' || typeof c === 'number') {
el.appendChild(document.createTextNode(String(c)));
} else {
el.appendChild(c);
}
}
return el;
};
// JSON Pointer (RFC 6901): encode one segment.
util.ptrEnc = function (s) {
return String(s).replace(/~/g, '~0').replace(/\//g, '~1');
};
util.ptrPush = function (path, segment) {
return path + '/' + util.ptrEnc(segment);
};
util.ptrParse = function (path) {
if (!path) {
return [];
}
return path.split('/').slice(1).map(function (s) {
return s.replace(/~1/g, '/').replace(/~0/g, '~');
});
};
let idCounter = 0;
util.uid = function (prefix) {
idCounter += 1;
return (prefix || 'f') + '-' + idCounter;
};
// Turn camelCase / snake_case into a Title Case string for default labels.
util.humanize = function (name) {
return String(name)
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^./, function (c) { return c.toUpperCase(); });
};
app.modules.util = util;
})(window.formApp);
(function (app) {
'use strict';
const u = app.modules.util;
// Build the standard label / description / input / help / error scaffold
// shared by all primitive widgets. Returns { wrap, errEl }.
function fieldContainer(opts) {
const wrap = u.h('div', { className: 'form-field' });
if (opts.label) {
const lbl = u.h('label', { className: 'form-field__label', for: opts.id });
lbl.appendChild(document.createTextNode(opts.label));
if (opts.required) {
lbl.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(lbl);
}
if (opts.description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, opts.description));
}
wrap.appendChild(opts.input);
if (opts.help) {
wrap.appendChild(u.h('div', { className: 'form-field__help' }, opts.help));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
return { wrap: wrap, errEl: errEl };
}
function coerceEnum(rawValue, options) {
for (let i = 0; i < options.length; i++) {
if (String(options[i]) === rawValue) {
return options[i];
}
}
return rawValue;
}
function makePrimitive(schema, ui, path, value, options) {
const id = u.uid('w');
const required = !!options.required;
const label = (ui && ui['ui:title']) || schema.title || options.fieldName || '';
const description = (ui && ui['ui:description']) || schema.description || '';
const help = (ui && ui['ui:help']) || '';
const placeholder = (ui && ui['ui:placeholder']) || '';
const widget = (ui && ui['ui:widget']) || '';
const readonly = !!(ui && ui['ui:readonly']);
const autofocus = !!(ui && ui['ui:autofocus']);
let input;
let read;
const t = schema.type;
if (t === 'boolean') {
// Render boolean as a single checkbox with an inline label, suppressing
// the standard label-above layout for cleaner UX.
const cb = u.h('input', { type: 'checkbox', id: id });
if (value === true) {
cb.checked = true;
}
if (readonly) {
cb.disabled = true;
}
const wrap = u.h('div', { className: 'form-field form-field--boolean' });
const inlineLabel = u.h('label', { for: id, className: 'form-field__checkbox-inline' });
inlineLabel.appendChild(cb);
inlineLabel.appendChild(document.createTextNode(' '));
inlineLabel.appendChild(document.createTextNode(label || ''));
if (required) {
inlineLabel.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(inlineLabel);
if (description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, description));
}
if (help) {
wrap.appendChild(u.h('div', { className: 'form-field__help' }, help));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
return widgetObject(wrap, errEl, path, function () {
return cb.checked;
});
}
if (Array.isArray(schema.enum)) {
const opts = schema.enum;
if (widget === 'radio') {
input = u.h('div', { className: 'form-field__radio-group' });
opts.forEach(function (opt, idx) {
const radioId = id + '-' + idx;
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: String(opt) });
if (value === opt) {
radio.checked = true;
}
if (readonly) {
radio.disabled = true;
}
const lbl = u.h('label', { for: radioId });
lbl.appendChild(radio);
lbl.appendChild(document.createTextNode(' ' + String(opt)));
input.appendChild(lbl);
});
read = function () {
const checked = input.querySelector('input[type="radio"]:checked');
return checked ? coerceEnum(checked.value, opts) : undefined;
};
} else {
input = u.h('select', { id: id, className: 'form-field__select' });
if (!required) {
input.appendChild(u.h('option', { value: '' }, '— select —'));
}
opts.forEach(function (opt) {
const o = u.h('option', { value: String(opt) }, String(opt));
if (value === opt) {
o.selected = true;
}
input.appendChild(o);
});
if (readonly) {
input.disabled = true;
}
read = function () {
if (input.value === '') {
return undefined;
}
return coerceEnum(input.value, opts);
};
}
} else if (t === 'number' || t === 'integer') {
input = u.h('input', {
type: 'number',
id: id,
className: 'form-field__input',
step: t === 'integer' ? '1' : 'any'
});
if (placeholder) {
input.placeholder = placeholder;
}
if (value != null) {
input.value = String(value);
}
if (readonly) {
input.readOnly = true;
}
if (autofocus) {
input.autofocus = true;
}
read = function () {
const v = input.value.trim();
if (v === '') {
return undefined;
}
const n = Number(v);
// If the user typed something non-numeric, return the raw string and
// let server validation produce a friendly error.
return Number.isFinite(n) ? n : v;
};
} else {
// Default: string-shaped input.
const fmt = schema.format;
if (widget === 'textarea') {
input = u.h('textarea', { id: id, className: 'form-field__textarea' });
} else {
let inputType = 'text';
if (fmt === 'date') {
inputType = 'date';
} else if (fmt === 'email') {
inputType = 'email';
}
input = u.h('input', { type: inputType, id: id, className: 'form-field__input' });
}
if (placeholder) {
input.placeholder = placeholder;
}
if (value != null) {
input.value = String(value);
}
if (readonly) {
input.readOnly = true;
}
if (autofocus) {
input.autofocus = true;
}
read = function () {
return input.value === '' ? undefined : input.value;
};
}
const built = fieldContainer({
id: id,
label: label,
description: description,
help: help,
required: required,
input: input
});
return widgetObject(built.wrap, built.errEl, path, read);
}
// Common widget shape used by both primitive and the wrapper above.
function widgetObject(wrapEl, errEl, path, read) {
return {
el: wrapEl,
path: path,
type: 'primitive',
read: read,
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
wrapEl.classList.add('form-field--invalid');
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
wrapEl.classList.remove('form-field--invalid');
},
child: function () { return null; }
};
}
app.modules.widgets = { makePrimitive: makePrimitive };
})(window.formApp);
(function (app) {
'use strict';
const u = app.modules.util;
function makeObject(schema, ui, path, value, options) {
const fs = u.h('fieldset', { className: 'form-fieldset' });
const label = (ui && ui['ui:title']) || schema.title || options.fieldName;
if (label) {
fs.appendChild(u.h('legend', { className: 'form-fieldset__legend' }, label));
}
if (schema.description) {
fs.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
fs.appendChild(errEl);
const props = schema.properties || {};
const requiredSet = {};
(schema.required || []).forEach(function (n) { requiredSet[n] = true; });
// Resolve render order: ui:order first (with '*' as "everything else"),
// then fall back to declaration order.
const declared = Object.keys(props);
const uiOrder = (ui && ui['ui:order']) || null;
const ordered = [];
const seen = {};
if (uiOrder && Array.isArray(uiOrder)) {
for (let i = 0; i < uiOrder.length; i++) {
const name = uiOrder[i];
if (name === '*') {
for (let j = 0; j < declared.length; j++) {
const dn = declared[j];
if (!seen[dn] && uiOrder.indexOf(dn) < 0) {
ordered.push(dn);
seen[dn] = true;
}
}
} else if (props[name] && !seen[name]) {
ordered.push(name);
seen[name] = true;
}
}
// Append anything declared but not mentioned in ui:order (and no '*' was used).
for (let j = 0; j < declared.length; j++) {
if (!seen[declared[j]]) {
ordered.push(declared[j]);
seen[declared[j]] = true;
}
}
} else {
for (let j = 0; j < declared.length; j++) {
ordered.push(declared[j]);
}
}
const children = {};
const dataObj = (value && typeof value === 'object' && !Array.isArray(value)) ? value : {};
for (let i = 0; i < ordered.length; i++) {
const name = ordered[i];
const childSchema = props[name];
const childUi = (ui && ui[name]) || {};
const childPath = u.ptrPush(path, name);
const childValue = dataObj[name];
const childWidget = app.modules.render.create(childSchema, childUi, childPath, childValue, {
fieldName: u.humanize(name),
required: !!requiredSet[name]
});
children[name] = childWidget;
fs.appendChild(childWidget.el);
}
return {
el: fs,
path: path,
type: 'object',
read: function () {
const out = {};
const keys = Object.keys(children);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const v = children[k].read();
if (v !== undefined) {
out[k] = v;
}
}
return out;
},
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
const keys = Object.keys(children);
for (let i = 0; i < keys.length; i++) {
children[keys[i]].clearErrors();
}
},
child: function (name) {
return children[name] || null;
}
};
}
app.modules.object = { makeObject: makeObject };
})(window.formApp);
(function (app) {
'use strict';
const u = app.modules.util;
function makeArray(schema, ui, path, value, options) {
const wrap = u.h('div', { className: 'form-field form-array' });
const label = (ui && ui['ui:title']) || schema.title || options.fieldName || '';
if (label) {
const lbl = u.h('label', { className: 'form-field__label' });
lbl.appendChild(document.createTextNode(label));
if (options.required) {
lbl.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(lbl);
}
if (schema.description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
const rowsEl = u.h('div', { className: 'form-array__rows' });
wrap.appendChild(rowsEl);
const itemSchema = schema.items || { type: 'string' };
const itemUi = (ui && ui.items) || {};
const uiOpts = (ui && ui['ui:options']) || {};
const addable = uiOpts.addable !== false;
const removable = uiOpts.removable !== false;
const rows = [];
function repath() {
for (let i = 0; i < rows.length; i++) {
rows[i].widget.path = u.ptrPush(path, String(i));
}
}
function addRow(rowValue) {
const idx = rows.length;
const rowPath = u.ptrPush(path, String(idx));
const childWidget = app.modules.render.create(itemSchema, itemUi, rowPath, rowValue, {
fieldName: '',
required: false
});
const rowEl = u.h('div', { className: 'form-array__row' });
const body = u.h('div', { className: 'form-array__row-body' });
body.appendChild(childWidget.el);
rowEl.appendChild(body);
if (removable) {
const actions = u.h('div', { className: 'form-array__row-actions' });
const removeBtn = u.h('button', {
type: 'button',
className: 'btn btn-small',
title: 'Remove this row',
onClick: function () { removeRow(rowEl); }
}, '×');
actions.appendChild(removeBtn);
rowEl.appendChild(actions);
}
rows.push({ widget: childWidget, rowEl: rowEl });
rowsEl.appendChild(rowEl);
}
function removeRow(targetEl) {
for (let i = 0; i < rows.length; i++) {
if (rows[i].rowEl === targetEl) {
rows.splice(i, 1);
targetEl.remove();
repath();
return;
}
}
}
const initial = Array.isArray(value) ? value : [];
for (let i = 0; i < initial.length; i++) {
addRow(initial[i]);
}
if (addable) {
const addBtn = u.h('button', {
type: 'button',
className: 'btn btn-small form-array__add',
onClick: function () { addRow(undefined); }
}, '+ Add');
wrap.appendChild(addBtn);
}
return {
el: wrap,
path: path,
type: 'array',
read: function () {
const out = [];
for (let i = 0; i < rows.length; i++) {
const v = rows[i].widget.read();
if (v !== undefined) {
out.push(v);
}
}
return out;
},
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
for (let i = 0; i < rows.length; i++) {
rows[i].widget.clearErrors();
}
},
child: function (idxStr) {
const i = parseInt(idxStr, 10);
return (rows[i] && rows[i].widget) || null;
}
};
}
app.modules.array = { makeArray: makeArray };
})(window.formApp);
(function (app) {
'use strict';
function create(schema, ui, path, value, options) {
options = options || {};
if (!schema) {
return app.modules.widgets.makePrimitive({ type: 'string' }, ui, path, value, options);
}
const t = schema.type;
if (t === 'object') {
return app.modules.object.makeObject(schema, ui, path, value, options);
}
if (t === 'array') {
return app.modules.array.makeArray(schema, ui, path, value, options);
}
// Anything else (string, number, integer, boolean, enum) falls through
// to the primitive widget which dispatches on schema.type / schema.enum.
return app.modules.widgets.makePrimitive(schema, ui, path, value, options);
}
function mount(rootEl, schema, ui, data) {
const widget = create(schema, ui, '', data, { fieldName: '', required: false });
rootEl.appendChild(widget.el);
return widget;
}
app.modules.render = { create: create, mount: mount };
})(window.formApp);
(function (app) {
'use strict';
function read() {
if (!app.rootWidget) {
return null;
}
return app.rootWidget.read();
}
app.modules.serialize = { read: read };
})(window.formApp);
(function (app) {
'use strict';
const u = app.modules.util;
function findByPath(root, path) {
if (!path || path === '') {
return root;
}
const segs = u.ptrParse(path);
let cur = root;
for (let i = 0; i < segs.length; i++) {
if (!cur || typeof cur.child !== 'function') {
return null;
}
cur = cur.child(segs[i]);
}
return cur || null;
}
function apply(errors) {
if (!errors || !errors.length || !app.rootWidget) {
return;
}
for (let i = 0; i < errors.length; i++) {
const err = errors[i];
const widget = findByPath(app.rootWidget, err.path || '');
if (widget && typeof widget.setError === 'function') {
widget.setError(err.message || 'Invalid value');
}
}
}
function clear() {
if (app.rootWidget) {
app.rootWidget.clearErrors();
}
}
app.modules.errors = { apply: apply, clear: clear };
})(window.formApp);
(function (app) {
'use strict';
function showStatus(msg, kind) {
const el = document.getElementById('form-status');
if (!el) {
return;
}
el.textContent = msg || '';
el.hidden = !msg;
el.classList.remove('is-error', 'is-success');
if (kind === 'error') {
el.classList.add('is-error');
} else if (kind === 'success') {
el.classList.add('is-success');
}
}
async function submit() {
if (!app.context || !app.context.submitUrl) {
showStatus('No submit URL configured.', 'error');
return;
}
const data = app.modules.serialize.read();
app.modules.errors.clear();
showStatus('', '');
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
submitBtn.disabled = true;
}
try {
const res = await fetch(app.context.submitUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.status === 200) {
showStatus('Saved.', 'success');
} else if (res.status === 201) {
const loc = res.headers.get('Location');
showStatus('Submitted.', 'success');
if (loc) {
// Capability URL for the new submission. Append .html to land
// on the form-rendered view of the just-saved data.
setTimeout(function () {
window.location.href = loc + '.html';
}, 400);
}
} else if (res.status === 422) {
let body = {};
try { body = await res.json(); } catch (e) { /* ignore */ }
app.modules.errors.apply(body.errors || []);
showStatus('Please correct the errors below.', 'error');
} else if (res.status === 403) {
showStatus('You are not allowed to submit here.', 'error');
} else if (res.status === 409) {
showStatus('A submission with this filename already exists.', 'error');
} else {
let detail = '';
try { detail = await res.text(); } catch (e) { /* ignore */ }
showStatus('Submission failed (' + res.status + ')' + (detail ? ': ' + detail : ''), 'error');
}
} catch (err) {
showStatus('Network error: ' + (err && err.message ? err.message : err), 'error');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
}
}
}
app.modules.post = { submit: submit, showStatus: showStatus };
})(window.formApp);
(function (app) {
'use strict';
// Friendly empty-state shown when the form is opened standalone
// (file:// or otherwise without a server-injected #form-context
// payload). The form renderer is always driven by the host —
// zddc-server's form handler injects schema+ui+data; the tool has
// no client-side picker because there's nothing it could pick from
// outside that contract.
function renderStandaloneWelcome(root) {
if (!root) return;
root.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'form-welcome';
wrap.innerHTML = [
'<h2>ZDDC Form Renderer</h2>',
'<p>This tool renders a form spec injected by <code>zddc-server</code>',
' at <code>&lt;name&gt;.form.html</code> URLs. There is no schema',
' to render here — most likely you opened the standalone HTML directly.</p>',
'<h3>To use it</h3>',
'<ol>',
'<li>Run <code>zddc-server</code> against an archive that contains a',
' <code>&lt;name&gt;.form.yaml</code> spec.</li>',
'<li>Visit <code>&lt;path&gt;/&lt;name&gt;.form.html</code> in the browser.</li>',
'</ol>',
'<p>See <a href="https://zddc.varasys.io/reference.html" target="_blank" rel="noopener">',
'zddc.varasys.io/reference.html</a> for the full ZDDC reference.</p>'
].join('');
root.appendChild(wrap);
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) submitBtn.hidden = true;
}
function boot() {
// When this bundle is hosted by the unified tables.html, the
// mode dispatcher decides which app paints. Skip when mode is
// not "form" — table-mode requests are handled by tablesApp.
// (Standalone form/dist/form.html has no zddcMode global; treat
// undefined as form-mode for back-compat.)
if (window.zddcMode && window.zddcMode !== 'form') {
return;
}
app.context = app.modules.context.load();
if (app.context.title) {
// Standalone form.html has #form-title in its header; unified
// tables.html bundle has #table-title (shared across modes).
// Whichever exists, write to it.
const t = document.getElementById('form-title') ||
document.getElementById('table-title');
if (t) {
t.textContent = app.context.title;
}
document.title = app.context.title + ' — ZDDC';
}
const root = document.getElementById('form-root');
if (root && app.context.schema) {
app.rootWidget = app.modules.render.mount(
root,
app.context.schema,
app.context.ui || {},
app.context.data
);
} else if (root) {
// No schema — server-injected context is empty. Most common
// when the standalone form.html is opened from file:// without
// a host. Show a friendly explanation instead of a blank page.
renderStandaloneWelcome(root);
return;
}
if (app.context.errors && app.context.errors.length) {
app.modules.errors.apply(app.context.errors);
app.modules.post.showStatus('Please correct the errors below.', 'error');
}
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
submitBtn.addEventListener('click', app.modules.post.submit);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})(window.formApp);
</script>
</body>
</html>