tablewalk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. package/package.json +57 -0
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Moving around a result without a mouse.
3
+ *
4
+ * A table of rows you can only click is a table you have to keep leaving the
5
+ * keyboard for. The query bar, the sidebar and the palette are all reachable
6
+ * by key; the grid the whole tool exists to show was the one surface that was
7
+ * not, so reading a wide result meant typing, then reaching for the mouse,
8
+ * then typing again.
9
+ *
10
+ * Arrow keys move a cell cursor, Enter opens the row's record — which is the
11
+ * walk, done from the keyboard. Everything else here follows from those two
12
+ * and from the grid pattern people already know from a spreadsheet.
13
+ *
14
+ * The position is held as `(row, column)` **indices, not elements**. Above a
15
+ * few hundred rows the table is virtualised and the `<tr>` under the cursor is
16
+ * destroyed the moment it scrolls out of the window, so a reference to it is a
17
+ * reference to a node that will be thrown away. Indices survive that; the
18
+ * element is looked up again on every move, and when the row has not been
19
+ * painted yet the scroller is moved and the lookup retried.
20
+ */
21
+ /** Rows moved by PageUp/PageDown when the viewport cannot be measured. */
22
+ const PAGE_FALLBACK = 20;
23
+
24
+ /** How many frames to wait for the virtualiser to paint a row we scrolled to. */
25
+ const PAINT_TRIES = 4;
26
+
27
+ /**
28
+ * @param {HTMLElement} wrap the element holding the table
29
+ * @param {object} options
30
+ * @param {HTMLTableSectionElement} options.tbody
31
+ * @param {number} options.rowCount logical rows, not painted ones
32
+ * @param {number} options.columnCount data columns
33
+ * @param {number} options.offset leading cells before the first data column
34
+ * @param {(index: number) => void} [options.onOpen] Enter on a row
35
+ */
36
+ export function initGrid(wrap, { tbody, rowCount, columnCount, offset = 0, onOpen, totalRows, firstRow = 0 }) {
37
+ if (!rowCount || !columnCount) return;
38
+
39
+ const table = wrap.querySelector('table');
40
+ if (!table) return;
41
+ table.setAttribute('role', 'grid');
42
+
43
+ /* How much there is, and where in it this page sits.
44
+
45
+ Without these a screen reader announces "row 3 of 20" on a table of
46
+ 63,010 — it can only count the rows in the DOM, and a paged grid puts
47
+ twenty of them there. The visual reader has "20 of 63,010 rows" in the
48
+ header; this is the same sentence for everyone else. `aria-rowindex` on
49
+ each row is what makes the count mean anything: it says which twenty. */
50
+ table.setAttribute('aria-colcount', String(columnCount + offset));
51
+ if (typeof totalRows === 'number') {
52
+ table.setAttribute('aria-rowcount', String(totalRows));
53
+ /* Every row carries its index, including on the first page. A count with
54
+ no indexes to anchor it is worse than neither: the reader is told there
55
+ are sixty-three thousand rows and given no way to know which of them
56
+ these twenty are. Header first — `aria-rowindex` is 1-based across the
57
+ whole grid, and the header is row 1 of it. */
58
+ table.querySelector('thead tr')?.setAttribute('aria-rowindex', '1');
59
+ for (let i = 0; i < rowCount; i += 1) {
60
+ tbody.querySelector(`tr[data-row="${i}"]`)?.setAttribute('aria-rowindex', String(firstRow + i + 2));
61
+ }
62
+ }
63
+
64
+ let row = -1;
65
+ let col = 0;
66
+
67
+ const scrollerOf = () => wrap.closest('.content') ?? document.scrollingElement;
68
+ const rowAt = (i) => tbody.querySelector(`tr[data-row="${i}"]`);
69
+ const cellAt = (i, c) => rowAt(i)?.children[c + offset] ?? null;
70
+
71
+ /** Rows that fit on screen, for PageUp and PageDown. */
72
+ const pageSize = () => {
73
+ const sample = tbody.querySelector('tr[data-row]');
74
+ const height = sample?.getBoundingClientRect().height;
75
+ const view = scrollerOf()?.clientHeight;
76
+ if (!height || !view) return PAGE_FALLBACK;
77
+ return Math.max(1, Math.floor(view / height) - 1);
78
+ };
79
+
80
+ /**
81
+ * Put the cursor on a cell, painting the row first if it does not exist.
82
+ *
83
+ * The retry is what makes this work on a virtualised table: scrolling
84
+ * schedules a repaint rather than performing one, so the row asked for is
85
+ * not in the DOM on the line after the scroll. Rather than guess at a delay,
86
+ * it looks again on the next frame, a few times, and gives up quietly — a
87
+ * cursor that does not move is better than one that throws.
88
+ */
89
+ const focusCell = (tries = PAINT_TRIES) => {
90
+ const cell = cellAt(row, col);
91
+ if (!cell) {
92
+ if (tries <= 0) return;
93
+ const sample = tbody.querySelector('tr[data-row]');
94
+ const height = sample?.getBoundingClientRect().height ?? 0;
95
+ const scroller = scrollerOf();
96
+ if (scroller && height) {
97
+ scroller.scrollTop = Math.max(0, row * height - scroller.clientHeight / 2);
98
+ }
99
+ requestAnimationFrame(() => focusCell(tries - 1));
100
+ return;
101
+ }
102
+ for (const stale of tbody.querySelectorAll('.cell-cursor')) {
103
+ stale.classList.remove('cell-cursor');
104
+ stale.removeAttribute('tabindex');
105
+ }
106
+ cell.classList.add('cell-cursor');
107
+ cell.tabIndex = 0;
108
+ /* The grid's tab stop moves to the cell, so tabbing in returns to where
109
+ the cursor was left rather than to the top-left corner — and the table
110
+ and the cell are never both stops, which would make Tab pass through
111
+ the same grid twice. */
112
+ table.tabIndex = -1;
113
+ cell.focus({ preventScroll: true });
114
+ /* `nearest` rather than `center`: the cursor usually moves one row, and
115
+ re-centring the table on every arrow press turns a walk down a column
116
+ into the page jumping under you. */
117
+ cell.scrollIntoView({ block: 'nearest', inline: 'nearest' });
118
+ };
119
+
120
+ const moveTo = (nextRow, nextCol) => {
121
+ row = Math.max(0, Math.min(rowCount - 1, nextRow));
122
+ col = Math.max(0, Math.min(columnCount - 1, nextCol));
123
+ focusCell();
124
+ };
125
+
126
+ /* Reachable by Tab.
127
+
128
+ One stop for the whole grid, not one per cell: a table of twenty rows and
129
+ eight columns is a hundred and sixty tab stops between the query bar and
130
+ whatever is after it, which is not navigation, it is a wall. */
131
+ table.tabIndex = 0;
132
+ table.addEventListener('focus', (e) => {
133
+ if (e.target !== table) return;
134
+ moveTo(row < 0 ? 0 : row, col);
135
+ });
136
+
137
+ // A click is also a way of saying where the cursor is, so the keyboard and
138
+ // the mouse do not disagree about where you are.
139
+ tbody.addEventListener('mousedown', (e) => {
140
+ const cell = e.target.closest('td');
141
+ const tr = cell?.closest('tr[data-row]');
142
+ if (!cell || !tr) return;
143
+ row = Number(tr.dataset.row);
144
+ col = Math.max(0, [...tr.children].indexOf(cell) - offset);
145
+ });
146
+
147
+ table.addEventListener('keydown', (e) => {
148
+ if (e.target.closest('input, textarea, select, [contenteditable]')) return;
149
+ if (e.altKey) return;
150
+
151
+ const jump = e.metaKey || e.ctrlKey;
152
+ switch (e.key) {
153
+ case 'ArrowDown': moveTo(jump ? rowCount - 1 : row + 1, col); break;
154
+ case 'ArrowUp': moveTo(jump ? 0 : row - 1, col); break;
155
+ case 'ArrowRight': moveTo(row, jump ? columnCount - 1 : col + 1); break;
156
+ case 'ArrowLeft': moveTo(row, jump ? 0 : col - 1); break;
157
+ case 'Home': moveTo(jump ? 0 : row, 0); break;
158
+ case 'End': moveTo(jump ? rowCount - 1 : row, columnCount - 1); break;
159
+ case 'PageDown': moveTo(row + pageSize(), col); break;
160
+ case 'PageUp': moveTo(row - pageSize(), col); break;
161
+ case 'Enter':
162
+ if (row >= 0 && onOpen) onOpen(row);
163
+ break;
164
+ case 'Escape':
165
+ /* Back to the query bar rather than nowhere. Escape from a grid means
166
+ "I am done here", and the thing you do next is nearly always type. */
167
+ table.blur();
168
+ document.getElementById('query')?.focus();
169
+ break;
170
+ case 'c':
171
+ if (!jump) return;
172
+ // The cell under the cursor, not the row: someone pressing ⌘C on a
173
+ // reference id wants that id.
174
+ void copyCell(cellAt(row, col));
175
+ break;
176
+ default:
177
+ return;
178
+ }
179
+ e.preventDefault();
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Copy one cell.
185
+ *
186
+ * `title` before `textContent`, because a value the table shortened shows an
187
+ * ellipsis and keeps the whole thing in its tooltip — copying what is drawn
188
+ * would hand back a truncated value that looks complete.
189
+ */
190
+ async function copyCell(cell) {
191
+ if (!cell) return;
192
+ const text = cell.getAttribute('title') ?? cell.textContent ?? '';
193
+ try {
194
+ await navigator.clipboard.writeText(text);
195
+ flash(cell);
196
+ } catch {
197
+ /* Clipboard access can be refused, and there is nothing useful to say
198
+ about it: the value is on screen and can be selected by hand. */
199
+ }
200
+ }
201
+
202
+ function flash(cell) {
203
+ cell.classList.add('cell-copied');
204
+ setTimeout(() => cell.classList.remove('cell-copied'), 400);
205
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Hand the walk to an agent.
3
+ *
4
+ * The MCP server is the agent→database half of this tool. This is the other
5
+ * half: a person walks to something interesting — three joins deep, a filter
6
+ * they arrived at by clicking — and now wants an agent to carry on from
7
+ * exactly there. Retyping the position into a chat window is where that
8
+ * hand-off usually dies, because the query is the easy part and the shape,
9
+ * the connection and the keys are the parts people leave out.
10
+ *
11
+ * What it writes is deliberately not the rows. The rows are on screen and
12
+ * the person can copy them; pasting them into a chat window sends them
13
+ * somewhere, and that should stay a decision rather than a side effect of
14
+ * clicking "copy". What it writes is everything an agent needs to fetch the
15
+ * rows itself, against the same connection, with the same question.
16
+ */
17
+
18
+ /** `name → type` with the markers the rest of the tool uses for keys and references. */
19
+ function columnLine(column) {
20
+ const parts = [column.name];
21
+ if (column.type) parts.push(column.type);
22
+ if (column.primaryKey) parts.push('key');
23
+ if (column.nullable === false && !column.primaryKey) parts.push('not null');
24
+ if (column.references) parts.push(`→ ${column.references.table}.${column.references.column}`);
25
+ return `- ${parts.join(' · ')}`;
26
+ }
27
+
28
+ /**
29
+ * The walk position as markdown, ready to paste into an agent.
30
+ *
31
+ * Pure so it can be pinned without a browser — the failure this feature has
32
+ * is silently omitting a field, and the only way to see that is to read the
33
+ * whole document a test wrote.
34
+ */
35
+ export function agentContext(position) {
36
+ const { connection, dialect, table, query, columns = [], shown, total, meta, trail = [], record } = position;
37
+ const lines = [];
38
+
39
+ lines.push(`# ${table} — a position in tablewalk`);
40
+ lines.push('');
41
+ lines.push(`Connection \`${connection ?? 'default'}\`${dialect ? ` (${dialect})` : ''}.`);
42
+ lines.push('');
43
+
44
+ if (record) {
45
+ lines.push(`One row of \`${table}\`: **${record.label}**`);
46
+ lines.push('');
47
+ lines.push('```json');
48
+ lines.push(JSON.stringify(record.key));
49
+ lines.push('```');
50
+ lines.push('');
51
+ } else {
52
+ lines.push('The question on screen is:');
53
+ lines.push('');
54
+ lines.push('```');
55
+ lines.push(query);
56
+ lines.push('```');
57
+ lines.push('');
58
+ }
59
+
60
+ if (!record && typeof shown === 'number') {
61
+ const of = typeof total === 'number' && total > shown
62
+ ? ` of ${total.toLocaleString()} matching`
63
+ : '';
64
+ lines.push(`Showing ${shown.toLocaleString()} row${shown === 1 ? '' : 's'}${of}.`);
65
+ lines.push('');
66
+ }
67
+
68
+ /* The columns the *query* returned, not the whole table: a projection is
69
+ part of the position, and an agent handed the full table would widen the
70
+ question without being told it had. */
71
+ if (!record && columns.length) {
72
+ lines.push(`Columns on screen: ${columns.join(', ')}`);
73
+ lines.push('');
74
+ }
75
+
76
+ if (meta?.columns?.length) {
77
+ lines.push(`## ${meta.id ?? table}`);
78
+ lines.push('');
79
+ for (const column of meta.columns) lines.push(columnLine(column));
80
+ lines.push('');
81
+ }
82
+
83
+ /* How they got here. A filter arrived at by three clicks reads as one
84
+ opaque `where` clause, and the trail is what makes it a story again. */
85
+ if (trail.length > 1) {
86
+ lines.push('## The walk');
87
+ lines.push('');
88
+ for (const step of trail) lines.push(`- ${step}`);
89
+ lines.push('');
90
+ }
91
+
92
+ lines.push('## Carry on from here');
93
+ lines.push('');
94
+ lines.push(`This database is available over MCP as \`tablewalk\`. ${
95
+ record ? 'To read this row and everything pointing at it:' : 'To see the same rows:'
96
+ }`);
97
+ lines.push('');
98
+ lines.push('```json');
99
+ lines.push(JSON.stringify(
100
+ record
101
+ ? { name: 'record', arguments: { connection: connection ?? undefined, table, key: record.key } }
102
+ : { name: 'query', arguments: { connection: connection ?? undefined, q: query, limit: shown ?? 20 } },
103
+ null,
104
+ 2,
105
+ ));
106
+ lines.push('```');
107
+ lines.push('');
108
+ lines.push(
109
+ '`table` describes a table, `find` says where a name or a value lives, and '
110
+ + '`query` runs the same language as the box above. The values themselves are '
111
+ + 'not included here — fetch them if you need them.',
112
+ );
113
+
114
+ return lines.join('\n');
115
+ }
116
+
117
+ /**
118
+ * Everything the current walk knows about where it is.
119
+ *
120
+ * Reads `state` rather than taking it apart at each call site, because the
121
+ * two callers — a result and a record — differ in one field and agreeing on
122
+ * the other seven by hand is how they drift.
123
+ */
124
+ export function positionOf(state, entry, extra = {}) {
125
+ const meta = state.schema?.tables?.find((t) => t.id === entry.table) ?? null;
126
+ return {
127
+ connection: state.activeConnection ?? state.schema?.label ?? null,
128
+ dialect: state.schema?.dialect ?? null,
129
+ table: entry.table,
130
+ query: entry.query ?? entry.table,
131
+ meta,
132
+ /* The trail as it reads in the breadcrumb: the query for a result, the
133
+ row's name for a record. */
134
+ trail: (state.stack ?? []).map((step) => (step.kind === 'table' ? step.query : step.label)),
135
+ ...extra,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Copy a position, and say so.
141
+ *
142
+ * The clipboard can be refused — an insecure origin, a permission denied —
143
+ * and a button that silently does nothing is worse than one that says it
144
+ * could not. The text is not shown as a fallback because it is a page long.
145
+ */
146
+ export async function copyPosition(position, toast) {
147
+ try {
148
+ await navigator.clipboard.writeText(agentContext(position));
149
+ toast('Position copied — paste it into an agent.', 'info');
150
+ } catch {
151
+ toast('The browser refused clipboard access, so nothing was copied.', 'error');
152
+ }
153
+ }
@@ -0,0 +1,145 @@
1
+ /* The query reference: the panel behind `?`, and the runnable examples on the
2
+ landing page.
3
+
4
+ In its own file rather than style.css because it is a self-contained feature
5
+ and the stylesheet is already 1600 lines. Nothing here names a colour: every
6
+ value is one of the tokens defined at the top of style.css, so the panel
7
+ follows the dark scheme without a second set of rules to keep in step. */
8
+
9
+ /* ---------- the landing page's examples ---------- */
10
+
11
+ .landing-examples { margin-bottom: 26px; }
12
+ .landing-examples-head {
13
+ display: flex; align-items: baseline; gap: 12px;
14
+ margin-bottom: 10px;
15
+ }
16
+ .landing-examples-head .landing-heading { margin: 0; }
17
+ .help-more { font-size: 11.5px; padding: 3px 9px; }
18
+
19
+ /* Wrapping columns rather than a row: three queries on a narrow window
20
+ otherwise shrink until the text they are teaching is ellipsised, which is
21
+ the one thing an example cannot survive. */
22
+ .help-starters {
23
+ display: grid; gap: 8px;
24
+ grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
25
+ }
26
+
27
+ /* ---------- an example, wherever it appears ---------- */
28
+
29
+ /* A button that looks like a line of query text, because that is what it puts
30
+ in the bar. The border only firms up under the pointer: a page of examples
31
+ each in its own box reads as a form to fill in. */
32
+ .help-example {
33
+ display: flex; flex-direction: column; align-items: flex-start; gap: 3px;
34
+ width: 100%; padding: 9px 11px; text-align: left; cursor: pointer;
35
+ background: var(--surface);
36
+ border: 1px solid var(--border); border-radius: 8px;
37
+ font: inherit; color: var(--text);
38
+ transition: border-color var(--t-hover) var(--ease-out),
39
+ background var(--t-hover) var(--ease-out);
40
+ }
41
+ .help-example:hover,
42
+ .help-example:focus-visible {
43
+ border-color: var(--accent); background: var(--accent-soft); outline: none;
44
+ }
45
+ .help-query {
46
+ font: 12.5px/1.5 var(--mono); color: var(--text);
47
+ /* Long paths wrap instead of scrolling sideways: an example you have to
48
+ scroll to read is one you cannot decide to click. */
49
+ overflow-wrap: anywhere;
50
+ }
51
+ .help-example:hover .help-query,
52
+ .help-example:focus-visible .help-query { color: var(--accent); }
53
+ .help-caption { font-size: 11.5px; color: var(--text-dim); }
54
+
55
+ /* ---------- the panel ---------- */
56
+
57
+ .help-backdrop {
58
+ position: fixed; inset: 0; z-index: 60;
59
+ background: rgb(0 0 0 / 0.28);
60
+ display: flex; justify-content: center;
61
+ padding: 8vh 16px 16px;
62
+ }
63
+ .help-panel {
64
+ width: min(720px, 96vw); max-height: 84vh;
65
+ display: flex; flex-direction: column;
66
+ background: var(--surface);
67
+ border: 1px solid var(--border-strong);
68
+ border-radius: 10px;
69
+ box-shadow: 0 18px 60px rgb(0 0 0 / 0.28);
70
+ overflow: hidden;
71
+ }
72
+ @media (prefers-reduced-motion: no-preference) {
73
+ .help-panel {
74
+ animation: help-in var(--t-panel) var(--ease-out);
75
+ }
76
+ @keyframes help-in {
77
+ from { opacity: 0; transform: translateY(-6px); }
78
+ }
79
+ }
80
+
81
+ .help-head {
82
+ display: flex; align-items: flex-start; gap: 12px;
83
+ padding: 14px 16px; flex: none;
84
+ border-bottom: 1px solid var(--border);
85
+ }
86
+ .help-title { margin: 0; font: 600 15px var(--sans); letter-spacing: -0.01em; }
87
+ .help-sub { margin: 3px 0 0; font-size: 12px; color: var(--text-dim); }
88
+ .help-close {
89
+ margin-left: auto; flex: none;
90
+ padding: 2px 8px; line-height: 1.2;
91
+ background: none; border: 1px solid transparent; border-radius: 6px;
92
+ color: var(--text-faint); cursor: pointer; font: 16px var(--mono);
93
+ }
94
+ .help-close:hover, .help-close:focus-visible {
95
+ color: var(--accent); border-color: var(--border); outline: none;
96
+ }
97
+
98
+ .help-body { overflow-y: auto; padding: 4px 16px 16px; }
99
+
100
+ .help-section { padding: 14px 0; border-bottom: 1px solid var(--border); }
101
+ .help-section:last-child { border-bottom: none; }
102
+ .help-section-title {
103
+ margin: 0 0 4px;
104
+ font: 600 10.5px var(--sans); text-transform: uppercase; letter-spacing: 0.07em;
105
+ color: var(--text-dim);
106
+ }
107
+ .help-note { margin: 0 0 10px; font-size: 12.5px; color: var(--text); max-width: 60ch; }
108
+
109
+ /* Syntax on the left, what it means on the right. A two-column grid rather
110
+ than a table so a long form wraps under itself instead of squeezing the
111
+ meaning into a column an inch wide. */
112
+ .help-grammar {
113
+ margin: 0 0 10px;
114
+ display: grid; grid-template-columns: minmax(9rem, auto) 1fr;
115
+ gap: 3px 14px; align-items: baseline;
116
+ }
117
+ .help-grammar > div { display: contents; }
118
+ .help-grammar dt { margin: 0; }
119
+ .help-grammar dt code {
120
+ font: 12px var(--mono); color: var(--accent);
121
+ background: var(--accent-soft); border-radius: 4px; padding: 1px 5px;
122
+ }
123
+ .help-grammar dd { margin: 0; font-size: 11.5px; color: var(--text-dim); }
124
+
125
+ .help-examples { display: grid; gap: 6px; }
126
+ .help-examples .help-example { background: var(--surface-2); }
127
+ .help-examples .help-example:hover,
128
+ .help-examples .help-example:focus-visible { background: var(--accent-soft); }
129
+
130
+ .help-absent { margin: 0; font-size: 12px; color: var(--text-faint); }
131
+
132
+ @media (max-width: 560px) {
133
+ .help-grammar { grid-template-columns: 1fr; gap: 1px; }
134
+ .help-grammar dd { margin-bottom: 6px; }
135
+ }
136
+
137
+ /* ---------- the examples, as the summary card's foot ---------- */
138
+
139
+ /* Inside the shape card they are a continuation of it, not a section: the
140
+ heading and the margin that separated two stacked panels were pure cost
141
+ once there was only one panel. */
142
+ .shape-body .landing-examples { margin: 12px 0 0; padding-top: 11px; border-top: 1px solid var(--border); }
143
+ .shape-body .landing-examples-head { margin-bottom: 8px; }
144
+ .shape-body .landing-heading { font: 10px var(--mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); }
145
+ .shape-body .help-example { padding: 7px 10px; }