diff --git a/tables/build.sh b/tables/build.sh
index 43c4610..7d6b207 100755
--- a/tables/build.sh
+++ b/tables/build.sh
@@ -24,6 +24,7 @@ concat_files \
"../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \
+ "../shared/context-menu.css" \
"css/table.css" \
"../form/css/form.css" \
> "$css_temp"
@@ -43,6 +44,7 @@ concat_files \
"../shared/logo.js" \
"../shared/help.js" \
"../shared/elevation.js" \
+ "../shared/context-menu.js" \
"js/mode.js" \
"js/app.js" \
"js/context.js" \
@@ -51,7 +53,9 @@ concat_files \
"js/sort.js" \
"js/editor.js" \
"js/undo.js" \
+ "js/add-row.js" \
"js/save.js" \
+ "js/row-ops.js" \
"js/clipboard.js" \
"js/render.js" \
"js/main.js" \
diff --git a/tables/css/table.css b/tables/css/table.css
index 60e414e..2309ca9 100644
--- a/tables/css/table.css
+++ b/tables/css/table.css
@@ -103,6 +103,14 @@
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
}
+/* Minimum row height so a freshly-added row (every cell empty) stays
+ visible — without this the row collapses to just cell padding and
+ looks like a thin divider line. Acts as a floor; rows with content
+ grow naturally to fit the text. */
+.zddc-table__row {
+ height: 2.4em;
+}
+
.zddc-table__row--readonly {
color: var(--color-text-muted);
}
diff --git a/tables/js/add-row.js b/tables/js/add-row.js
new file mode 100644
index 0000000..d8b1f0f
--- /dev/null
+++ b/tables/js/add-row.js
@@ -0,0 +1,109 @@
+// add-row.js — inline new-row creation.
+//
+// Click "+ Add row" → append a draft row at the end of state.rows,
+// focus its first editable cell, accumulate user typing into the
+// drafts buffer like any other row. On row-blur, save.js detects the
+// row.isNew flag and POSTs to
/form.html (the form-create
+// endpoint). The 201 response carries the new row's Location; we swap
+// the synthetic url/yamlUrl for the real ones and the draft row
+// becomes a normal saved row.
+//
+// Synthetic identity: each new row gets a temporary "__new-" url
+// so rowKey() returns something unique for selection + draft tracking.
+// The temporary url is replaced after a successful POST. There is no
+// "save on click" UX — the existing row-blur trigger is the save path,
+// same as for edits.
+(function (app) {
+ 'use strict';
+
+ let _counter = 0;
+
+ function makeSyntheticKey() {
+ _counter += 1;
+ return '__new-' + _counter;
+ }
+
+ // Compute the form-create URL for the current page. Both
+ // //table.html and // (default_tool: tables) shape work;
+ // //form.html is the form handler's "create" endpoint either
+ // way (the form handler keys off the in-dir convention, not the
+ // visiting URL shape).
+ function formCreateUrl() {
+ let dir = (location.pathname || '/').replace(/\/table\.html$/, '/');
+ if (!dir.endsWith('/')) dir += '/';
+ return dir + 'form.html';
+ }
+
+ // Create-and-paint: the user-facing path.
+ function invoke() {
+ const key = createSilent();
+ if (typeof app.repaint === 'function') app.repaint();
+ focusNewRow(key);
+ }
+
+ // Push a draft row WITHOUT painting or focusing. Used by multi-row
+ // paste (clipboard.js) to create N rows in a single batch, with one
+ // paint at the end. Returns the synthetic url so callers can address
+ // the new row in their draft writes.
+ function createSilent() {
+ const key = makeSyntheticKey();
+ const draftRow = {
+ url: key,
+ yamlUrl: null,
+ data: {},
+ etag: null,
+ editable: true,
+ isNew: true,
+ };
+ if (!Array.isArray(app.state.rows)) {
+ app.state.rows = [];
+ }
+ app.state.rows.push(draftRow);
+ return key;
+ }
+
+ function focusNewRow(key) {
+ // After repaint, find the tr with our synthetic data-row-id and
+ // tell the editor to select its first cell. Filtering may have
+ // hidden the new row if a default filter excludes it; we accept
+ // that — clearing filters surfaces it.
+ const tbody = document.querySelector('#table-root tbody');
+ if (!tbody) return;
+ const trs = tbody.querySelectorAll('tr');
+ for (let i = 0; i < trs.length; i++) {
+ if (trs[i].getAttribute('data-row-id') === key) {
+ const editor = app.modules.editor;
+ if (editor && typeof editor.setSelected === 'function') {
+ // Scroll into view so the user sees the new row.
+ trs[i].scrollIntoView({ block: 'nearest', behavior: 'auto' });
+ editor.setSelected(i, 0);
+ }
+ return;
+ }
+ }
+ }
+
+ // Cancel-new-row helper: drop the synthetic row entirely. Used when
+ // the user adds a row, makes no edits, and clicks Add again or
+ // navigates away — there's nothing to save and an empty draft just
+ // clutters the table. The save module calls this from row-blur when
+ // it sees a new row with no drafts.
+ function discardEmpty(rowId) {
+ const rows = app.state.rows || [];
+ for (let i = 0; i < rows.length; i++) {
+ if (rows[i].isNew && rows[i].url === rowId) {
+ rows.splice(i, 1);
+ if (typeof app.repaint === 'function') app.repaint();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ app.modules.addRow = {
+ invoke: invoke,
+ createSilent: createSilent,
+ formCreateUrl: formCreateUrl,
+ discardEmpty: discardEmpty,
+ };
+})(window.tablesApp);
diff --git a/tables/js/clipboard.js b/tables/js/clipboard.js
index ae5b528..b1fc91e 100644
--- a/tables/js/clipboard.js
+++ b/tables/js/clipboard.js
@@ -119,17 +119,32 @@
// --- Apply paste --------------------------------------------------
function applyPaste(anchorRowIdx, anchorColIdx, grid) {
- // grid is string[][]. Returns {applied: int, skipped: int}.
+ // grid is string[][]. Returns {applied: int, skipped: int, created: int}.
+ // When the paste extends past the last existing row, the
+ // add-row module creates new draft rows on the fly so an Excel
+ // copy lands as a complete data set, not a clipped one. Each
+ // new row will save on its own row-blur (POST to form-create).
const ed = editor();
const totalRows = visibleRowCount();
const cols = (app.context && app.context.columns) || [];
const totalCols = cols.length;
- let applied = 0, skipped = 0;
+ const addRow = app.modules.addRow;
+ let applied = 0, skipped = 0, created = 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);
+ let row = null;
+ if (dstR < totalRows) {
+ row = rowDataAtIndex(dstR);
+ } else if (addRow && typeof addRow.createSilent === 'function') {
+ addRow.createSilent();
+ created++;
+ // After createSilent the new row is at the end of
+ // state.rows but the DOM hasn't repainted yet — pull
+ // straight from state.rows to address it.
+ const all = (app.state && app.state.rows) || [];
+ row = all[all.length - 1];
+ }
if (!row) { skipped += grid[r].length; continue; }
for (let c = 0; c < grid[r].length; c++) {
const dstC = anchorColIdx + c;
@@ -141,7 +156,7 @@
applied++;
}
}
- return { applied: applied, skipped: skipped };
+ return { applied: applied, skipped: skipped, created: created };
}
function visibleRowCount() {
@@ -208,11 +223,15 @@
const result = applyPaste(r, c, grid);
// Trigger a re-paint so draft values display.
if (typeof app.repaint === 'function') app.repaint();
+ let msg = 'Pasted ' + result.applied + ' cell' + plural(result.applied);
+ if (result.created > 0) {
+ msg += ' into ' + result.created + ' new row' + plural(result.created);
+ }
if (result.skipped > 0) {
- notifyToast(
- 'Pasted ' + result.applied + ' cell' + plural(result.applied) +
- '; ' + result.skipped + ' dropped (out of bounds)'
- );
+ msg += '; ' + result.skipped + ' dropped (out of bounds)';
+ }
+ if (result.created > 0 || result.skipped > 0) {
+ notifyToast(msg);
}
}
diff --git a/tables/js/main.js b/tables/js/main.js
index 21d34d5..3caef80 100644
--- a/tables/js/main.js
+++ b/tables/js/main.js
@@ -31,18 +31,33 @@
const clearBtn = document.getElementById('table-clear-filters');
const addRowBtn = document.getElementById('table-add-row');
- // Add-row button: link to .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).
+ // Add-row button: appends a draft row inline. Save fires on
+ // row-blur, which POSTs to /form.html and swaps the
+ // synthetic row id for the server's response. The button shows
+ // whenever the page is a real table view (http(s) + a table
+ // context loaded with columns) — the test-fixture inline-context
+ // harness opens tables.html directly with no URL shape, so we
+ // gate on having a column list AND running over http(s).
if (addRowBtn) {
- // Page is at /table.html; the row-creation form is at
- // /form.html — same directory, just swap the basename.
- if (/\/table\.html$/.test(location.pathname || '')) {
- addRowBtn.href = 'form.html';
+ const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
+ const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
+ if (onHttp && hasCols) {
addRowBtn.hidden = false;
+ addRowBtn.removeAttribute('href');
+ addRowBtn.setAttribute('role', 'button');
+ addRowBtn.setAttribute('tabindex', '0');
+ addRowBtn.style.cursor = 'pointer';
+ const handleAdd = function (ev) {
+ ev.preventDefault();
+ const addRow = app.modules.addRow;
+ if (addRow && typeof addRow.invoke === 'function') {
+ addRow.invoke();
+ }
+ };
+ addRowBtn.addEventListener('click', handleAdd);
+ addRowBtn.addEventListener('keydown', function (ev) {
+ if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev);
+ });
}
}
@@ -106,6 +121,12 @@
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
}
}
+ // Row context menu re-attaches each paint — renderBody wipes
+ // the tbody, taking listeners with it.
+ const rowOps = app.modules.rowOps;
+ if (rowOps && typeof rowOps.attach === 'function') {
+ rowOps.attach();
+ }
// Re-apply Phase-3 dirty-row markers — tbody.innerHTML='' in
// renderBody wiped them.
const save = app.modules.save;
diff --git a/tables/js/row-ops.js b/tables/js/row-ops.js
new file mode 100644
index 0000000..1e9e3f1
--- /dev/null
+++ b/tables/js/row-ops.js
@@ -0,0 +1,201 @@
+// row-ops.js — row-level operations (delete, future: duplicate,
+// copy-to-table, etc.). Surfaced via a right-click context menu on
+// table rows; the editor's selection state determines which row the
+// action targets when the menu is invoked from the keyboard or from a
+// future toolbar button.
+//
+// The shared context-menu primitive (window.zddc.menu) drives the
+// rendering and keyboard behaviour. This module owns the menu spec
+// and the action handlers.
+(function (app) {
+ 'use strict';
+
+ function findRowById(rowId) {
+ const all = (app.state && app.state.rows) || [];
+ for (let i = 0; i < all.length; i++) {
+ const editor = app.modules.editor;
+ const key = editor ? editor.rowKey(all[i]) : (all[i].url || '');
+ if (key === rowId) return all[i];
+ }
+ return null;
+ }
+
+ function removeRowFromState(row) {
+ const all = app.state.rows || [];
+ const idx = all.indexOf(row);
+ if (idx >= 0) all.splice(idx, 1);
+ // Drop any drafts keyed on the row's url.
+ if (app.state.drafts && row.url) {
+ delete app.state.drafts[row.url];
+ }
+ }
+
+ function rowDisplayName(row) {
+ if (!row) return '(unknown)';
+ if (row.isNew) return '(unsaved new row)';
+ if (row.yamlUrl) {
+ const m = row.yamlUrl.match(/[^/]+$/);
+ if (m) return m[0];
+ }
+ return row.url || '(row)';
+ }
+
+ async function deleteRow(rowId) {
+ const row = findRowById(rowId);
+ if (!row) return { status: 'noop' };
+ if (row.editable === false) return { status: 'readonly' };
+
+ // Unsaved new row: just drop it. Nothing to call.
+ if (row.isNew) {
+ removeRowFromState(row);
+ if (typeof app.repaint === 'function') app.repaint();
+ return { status: 'ok-local' };
+ }
+
+ if (!row.yamlUrl) {
+ // file:// or fixture context — nothing to delete server-side.
+ removeRowFromState(row);
+ if (typeof app.repaint === 'function') app.repaint();
+ return { status: 'ok-local' };
+ }
+
+ const ok = window.confirm('Delete row "' + rowDisplayName(row) + '"?\n\nThis cannot be undone.');
+ if (!ok) return { status: 'cancelled' };
+
+ const headers = {};
+ if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
+ let resp;
+ try {
+ resp = await fetch(row.yamlUrl, {
+ method: 'DELETE',
+ headers: headers,
+ credentials: 'same-origin'
+ });
+ } catch (err) {
+ window.alert('Delete failed: ' + (err && err.message ? err.message : err));
+ return { status: 'network-error', error: err };
+ }
+ if (resp.status === 200 || resp.status === 204) {
+ removeRowFromState(row);
+ if (typeof app.repaint === 'function') app.repaint();
+ return { status: 'ok' };
+ }
+ if (resp.status === 412) {
+ window.alert('Cannot delete: this row was changed since you loaded it. Reload to see the latest version.');
+ return { status: 'conflict' };
+ }
+ let body = '';
+ try { body = await resp.text(); } catch (_) { /* ignore */ }
+ window.alert('Delete failed (' + resp.status + '): ' + body);
+ return { status: 'http-error', code: resp.status };
+ }
+
+ // Returns the list of visible-row indices currently included in
+ // the editor's range selection. Empty when no range is active.
+ function rangeRowIndices() {
+ const range = app.state && 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 out = [];
+ for (let r = r0; r <= r1; r++) out.push(r);
+ return out;
+ }
+
+ // Map a visible-row index to its data-row-id (synthetic or real).
+ function rowIdAtIndex(idx) {
+ const trs = document.querySelectorAll('#table-root tbody > tr');
+ const tr = trs[idx];
+ return tr ? tr.getAttribute('data-row-id') : null;
+ }
+
+ async function deleteRows(rowIds) {
+ if (!rowIds || rowIds.length === 0) return { status: 'noop' };
+ if (rowIds.length === 1) return deleteRow(rowIds[0]);
+ const ok = window.confirm('Delete ' + rowIds.length + ' rows?\n\nThis cannot be undone.');
+ if (!ok) return { status: 'cancelled' };
+ // Walk back-to-front so removing by index from state.rows
+ // doesn't shift the indices of pending deletes.
+ let okCount = 0, failCount = 0;
+ for (let i = rowIds.length - 1; i >= 0; i--) {
+ const row = findRowById(rowIds[i]);
+ if (!row) continue;
+ if (row.isNew || !row.yamlUrl) {
+ removeRowFromState(row);
+ okCount++;
+ continue;
+ }
+ const headers = {};
+ if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
+ try {
+ const resp = await fetch(row.yamlUrl, {
+ method: 'DELETE',
+ headers: headers,
+ credentials: 'same-origin'
+ });
+ if (resp.status === 200 || resp.status === 204) {
+ removeRowFromState(row);
+ okCount++;
+ } else {
+ failCount++;
+ }
+ } catch (_err) {
+ failCount++;
+ }
+ }
+ if (typeof app.repaint === 'function') app.repaint();
+ if (failCount > 0) {
+ window.alert('Deleted ' + okCount + ' row(s); ' + failCount + ' failed.');
+ }
+ return { status: 'ok', deleted: okCount, failed: failCount };
+ }
+
+ function buildRowMenu(ctx) {
+ const rangeRows = ctx.rangeRowIds || [];
+ const inRange = rangeRows.length > 1 && rangeRows.indexOf(ctx.rowId) !== -1;
+ const targets = inRange ? rangeRows : [ctx.rowId];
+ const label = targets.length > 1 ? 'Delete ' + targets.length + ' rows' : 'Delete row';
+ return [
+ {
+ label: label,
+ icon: '🗑',
+ danger: true,
+ disabled: !ctx.row || ctx.row.editable === false,
+ action: function () {
+ if (targets.length > 1) deleteRows(targets);
+ else deleteRow(targets[0]);
+ }
+ }
+ ];
+ }
+
+ function onRowContext(ev) {
+ const tr = ev.target.closest('tr[data-row-id]');
+ if (!tr) return;
+ const rowId = tr.getAttribute('data-row-id');
+ const row = findRowById(rowId);
+ if (!row) return;
+ ev.preventDefault();
+ const menu = window.zddc && window.zddc.menu;
+ if (!menu || typeof menu.open !== 'function') return;
+ const rangeRowIds = rangeRowIndices().map(rowIdAtIndex).filter(Boolean);
+ menu.open({
+ x: ev.clientX,
+ y: ev.clientY,
+ items: buildRowMenu({ row: row, rowId: rowId, rangeRowIds: rangeRowIds }),
+ context: { row: row, rowId: rowId, rangeRowIds: rangeRowIds }
+ });
+ }
+
+ function attach() {
+ const tbody = document.querySelector('#table-root tbody');
+ if (!tbody) return;
+ tbody.addEventListener('contextmenu', onRowContext);
+ }
+
+ app.modules.rowOps = {
+ attach: attach,
+ deleteRow: deleteRow,
+ deleteRows: deleteRows,
+ };
+})(window.tablesApp);
diff --git a/tables/js/save.js b/tables/js/save.js
index 1cdba94..5c3f1cb 100644
--- a/tables/js/save.js
+++ b/tables/js/save.js
@@ -177,8 +177,21 @@
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) return { status: 'noop' };
+ const hasDrafts = drafts && Object.keys(drafts).length > 0;
+ // New (unsaved) rows: if the user added a row and then moved on
+ // without typing anything, drop the empty placeholder rather
+ // than POST an empty body that fails schema validation.
+ if (row.isNew && !hasDrafts) {
+ const addRow = app.modules.addRow;
+ if (addRow && typeof addRow.discardEmpty === 'function') {
+ addRow.discardEmpty(rowId);
+ }
+ return { status: 'discarded-empty' };
+ }
+ if (!hasDrafts) return { status: 'noop' };
+ if (row.isNew) {
+ return createRow(rowId, row, drafts, opts);
}
if (!row.yamlUrl) {
// file:// mode or rows from inline-context test fixtures
@@ -281,6 +294,84 @@
return { status: 'http-error', code: resp.status };
}
+ // createRow handles the POST path for an isNew row. Body is YAML of
+ // the row's draft data (no row.data yet — it's a fresh row). Success
+ // is 201 + Location pointing at the new .yaml; we swap the
+ // synthetic url/yamlUrl for the real ones and clear isNew so the
+ // row behaves like any other from this point on.
+ async function createRow(rowId, row, drafts, opts) {
+ const addRow = app.modules.addRow;
+ if (!addRow || typeof addRow.formCreateUrl !== 'function') {
+ setRowState(rowId, 'errored');
+ return { status: 'no-create-url' };
+ }
+ const createUrl = addRow.formCreateUrl();
+ const merged = mergeRow(row.data, drafts);
+ const yamlBody = window.jsyaml.dump(merged);
+
+ const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
+ const fetchOpts = {
+ method: 'POST',
+ body: yamlBody,
+ headers: headers,
+ credentials: 'same-origin',
+ };
+ if (opts && opts.keepalive) fetchOpts.keepalive = true;
+
+ setRowState(rowId, 'saving');
+ let resp;
+ try {
+ resp = await fetch(createUrl, fetchOpts);
+ } catch (err) {
+ console.error('[tables] createRow network error', err);
+ setRowState(rowId, 'errored');
+ return { status: 'network-error', error: err };
+ }
+
+ if (resp.status === 201) {
+ // Server wrote the row. Body is {location, filename}; we
+ // also accept the Location header if the body isn't JSON.
+ let body = {};
+ try { body = await resp.json(); } catch (_) { /* ignore */ }
+ const location = body.location || resp.headers.get('Location') || '';
+ const newEtag = (resp.headers.get('ETag') || '').replace(/"/g, '');
+ row.yamlUrl = location;
+ row.url = location ? location + '.html' : row.url;
+ row.data = merged;
+ row.etag = newEtag || null;
+ row.isNew = false;
+ // Move the drafts entry (was keyed on the synthetic id) to
+ // the new url, then clear it (data has the merged values).
+ delete app.state.drafts[rowId];
+ clearCellInvalid(rowId);
+ setRowState(rowId, '');
+ const sb = document.getElementById('table-status');
+ if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
+ // Re-paint so the row picks up its new data-row-id and any
+ // server-supplied default fields surface.
+ if (typeof app.repaint === 'function') app.repaint();
+ return { status: 'ok' };
+ }
+
+ if (resp.status === 422) {
+ 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 };
+ }
+
+ console.warn('[tables] createRow 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;
diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html
index 7b79155..20acf84 100644
--- a/zddc/internal/handler/tables.html
+++ b/zddc/internal/handler/tables.html
@@ -963,6 +963,116 @@ body.help-open .app-header {
outline-offset: 2px;
}
+/* shared/context-menu.css — generic styles for window.zddc.menu.
+ Mirrors the look-and-feel of native context menus: tight rows,
+ five-column grid (check | icon | label | accel | arrow), subtle
+ border + shadow, hover background from the shared --bg-hover token,
+ danger items tinted with --danger. */
+
+.zddc-menu {
+ position: fixed;
+ z-index: 10000;
+ min-width: 12rem;
+ max-width: 22rem;
+ padding: 0.25rem 0;
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18),
+ 0 2px 6px rgba(0, 0, 0, 0.10);
+ font-family: var(--font);
+ font-size: 0.85rem;
+ line-height: 1.2;
+ user-select: none;
+ /* Allow focus styles inside without leaking to the menu itself. */
+ outline: none;
+}
+
+.zddc-menu__sep {
+ height: 1px;
+ margin: 0.25rem 0;
+ background: var(--border);
+}
+
+.zddc-menu__item {
+ display: grid;
+ grid-template-columns: 1.1rem 1.25rem 1fr auto 0.9rem;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.3rem 0.7rem;
+ cursor: pointer;
+ color: var(--text);
+ /* Suppress the focus ring on the row itself — hover/focus
+ background handles the cue. */
+ outline: none;
+}
+
+.zddc-menu__item:hover,
+.zddc-menu__item:focus,
+.zddc-menu__item:focus-visible {
+ background: var(--bg-hover);
+}
+
+.zddc-menu__item.is-disabled {
+ color: var(--text-muted);
+ cursor: default;
+}
+
+.zddc-menu__item.is-disabled:hover,
+.zddc-menu__item.is-disabled:focus {
+ background: transparent;
+}
+
+.zddc-menu__item--danger {
+ color: var(--danger);
+}
+
+.zddc-menu__item--danger:hover,
+.zddc-menu__item--danger:focus {
+ background: var(--danger);
+ color: var(--text-light);
+}
+
+.zddc-menu__check {
+ font-size: 0.9rem;
+ text-align: center;
+ color: var(--primary);
+}
+
+.zddc-menu__icon {
+ font-size: 0.95rem;
+ text-align: center;
+}
+
+.zddc-menu__label {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.zddc-menu__accel {
+ color: var(--text-muted);
+ font-size: 0.78rem;
+ font-variant-numeric: tabular-nums;
+ padding-left: 0.5rem;
+}
+
+.zddc-menu__item--danger .zddc-menu__accel {
+ color: inherit;
+ opacity: 0.85;
+}
+
+.zddc-menu__arrow {
+ color: var(--text-muted);
+ font-size: 0.7rem;
+ text-align: center;
+}
+
+.zddc-menu__item--has-sub .zddc-menu__arrow {
+ color: var(--text);
+}
+
/* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
.table-main {
@@ -1068,6 +1178,14 @@ body.help-open .app-header {
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
}
+/* Minimum row height so a freshly-added row (every cell empty) stays
+ visible — without this the row collapses to just cell padding and
+ looks like a thin divider line. Acts as a floor; rows with content
+ grow naturally to fit the text. */
+.zddc-table__row {
+ height: 2.4em;
+}
+
.zddc-table__row--readonly {
color: var(--color-text-muted);
}
@@ -1375,7 +1493,7 @@ body.help-open .app-header {