@lnsy/data-table 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,26 @@
1
+ The Unlicense
2
+
3
+ This is free and unencumbered software released into the public domain.
4
+
5
+ Anyone is free to copy, modify, publish, use, compile, sell, or
6
+ distribute this software, either in source code form or as a compiled
7
+ binary, for any purpose, commercial or non-commercial, and by any
8
+ means.
9
+
10
+ In jurisdictions that recognize copyright laws, the author or authors
11
+ of this software dedicate any and all copyright interest in the
12
+ software to the public domain. We make this dedication for the benefit
13
+ of the public at large and to the detriment of our heirs and
14
+ successors. We intend this dedication to be an overt act of
15
+ relinquishment in perpetuity of all present and future rights to this
16
+ software under copyright law.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
21
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
22
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
23
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24
+ OTHER DEALINGS IN THE SOFTWARE.
25
+
26
+ For more information, please refer to <https://unlicense.org>
package/README.md ADDED
@@ -0,0 +1,229 @@
1
+ # @lnsy/data-table
2
+
3
+ A dependency-free tabular data component (`<data-table>`) built on vanilla
4
+ JS, CSS and HTML — no jspreadsheet, no grid library.
5
+
6
+ One custom element, three presentation modes, all backed by the same data
7
+ model and formula engine.
8
+
9
+ ## Features
10
+
11
+ ### Three modes, one engine
12
+
13
+ Pick a presentation with the `mode` attribute. The data model, formulas and
14
+ `[[wikilinks]]` behave identically in all three:
15
+
16
+ | Mode | Attribute | What you get |
17
+ | --- | --- | --- |
18
+ | Spreadsheet | `mode="spreadsheet"` (default) | Full grid with A/B/C column headers and numbered rows, a formula bar, range selection, in-cell editing, and resizable columns and rows. |
19
+ | Table | `mode="table"` | Searchable, sortable record table. The first data row becomes the header; click a header to sort ascending/descending (numeric-aware); a live counter shows filtered vs. total records. |
20
+ | List | `mode="list"` | Searchable single-column list built from the first column; empty cells are skipped. |
21
+
22
+ ### Spreadsheet editing
23
+
24
+ - Click to select; drag or Shift+Arrow to extend a range selection; arrow
25
+ keys and Tab/Shift+Tab move the active cell.
26
+ - Double-click, press Enter, or just start typing to edit a cell. Enter
27
+ commits and moves down, Tab/Shift+Tab commits and moves sideways, Escape
28
+ cancels.
29
+ - Delete/Backspace clears the selection.
30
+ - Drag the edge of a column or row header to resize it; double-click the
31
+ edge to snap back to the default size.
32
+ - The formula bar shows the raw contents of the active cell (formulas
33
+ included), and cells that contain formulas are visually flagged.
34
+ - `read-only` disables all editing while keeping selection, navigation and
35
+ copy.
36
+
37
+ ### Formulas
38
+
39
+ Cells starting with `=` are evaluated by the built-in, dependency-free
40
+ formula engine (`src/formula.js`):
41
+
42
+ ```text
43
+ =SUM(A1:A5) =IF(A1>2, "yes", "no")
44
+ =A1&B1 =ROUND(A1*B1, 2)
45
+ =IFERROR(HTTP(…), "n/a") =CONCAT("Q", 1)
46
+ ```
47
+
48
+ - **References** — `A1`, absolute `\$B\$2`, and ranges `A1:B10` (ranges are
49
+ valid inside functions).
50
+ - **Operators** — `+ - * / ^ %` (modulo), `&` (string concat), and
51
+ comparisons `= <> < > <= >=`, with parentheses and unary minus.
52
+ - **Literals** — numbers, `"strings"` (escape `"` as `""`), `TRUE`/`FALSE`.
53
+
54
+ | Category | Functions |
55
+ | --- | --- |
56
+ | Math | `SUM`, `AVERAGE`/`AVG`, `MIN`, `MAX`, `COUNT`, `COUNTA`, `ABS`, `ROUND`, `FLOOR`, `CEILING`/`CEIL`, `SQRT`, `POWER`/`POW`, `MOD` |
57
+ | Text | `CONCAT`/`CONCATENATE`, `LEN`, `UPPER`, `LOWER`, `TRIM` |
58
+ | Logic | `IF`, `IFERROR`, `AND`, `OR`, `NOT` |
59
+ | Date | `TODAY`, `NOW` |
60
+
61
+ `IF` and `IFERROR` short-circuit: only the taken branch is evaluated, which
62
+ matters when a branch hits the network. Circular references are detected and
63
+ reported as `#CIRCULAR!`; other errors surface as `#SYNTAX!`, `#NAME?`,
64
+ `#HTTP!` or `#NOENDPOINT!`.
65
+
66
+ ### Live data from formulas
67
+
68
+ Formulas can fetch over HTTP, with dotted-path extraction into the parsed
69
+ JSON (numeric segments index arrays):
70
+
71
+ ```text
72
+ =HTTP("https://api.example.com/status")
73
+ =HTTP("https://api.frankfurter.app/latest?from=USD", "rates.EUR")
74
+ =HTTPJSON("https://…", "items.0.name")
75
+ =ENDPOINT("rates", "rates.EUR")
76
+ ```
77
+
78
+ - `HTTP(url)` returns the response body (text, or stringified JSON); pass a
79
+ second argument to extract a value from the parsed JSON.
80
+ - `HTTPJSON(url, path)` always parses the body as JSON.
81
+ - `ENDPOINT("name", path?)` reads a URL registered from JavaScript:
82
+
83
+ ```js
84
+ table.setEndpoints({ rates: 'https://api.frankfurter.app/latest?from=USD' });
85
+ ```
86
+
87
+ Responses are cached for 30 seconds and concurrent identical requests are
88
+ deduplicated; failed requests are only cached for 3 seconds so retries
89
+ happen quickly.
90
+
91
+ ### Wikilinks
92
+
93
+ `[[Page Name]]` and `[[Page Name|label]]` render as links inside any cell:
94
+
95
+ - Set `wikilink-base-url="https://…/"` and links open
96
+ `${base}${encodeURIComponent(target)}` in a new tab.
97
+ - Otherwise, clicking a link dispatches a bubbling `wikilink-navigate` event
98
+ with `{ detail: { target } }`, which the host application can handle
99
+ (routing, filtering, opening a panel…).
100
+
101
+ ### Copy & paste
102
+
103
+ Ctrl/Cmd+C/X/V work inside the grid and interoperate with Excel, Numbers
104
+ and Google Sheets via tab-separated values. Pasting a multi-cell block
105
+ expands the grid to fit. Programmatic equivalents: `selectionToTSV()`,
106
+ `copySelection()` and `pasteText(text)`.
107
+
108
+ ### File I/O
109
+
110
+ The helpers in `src/file-io.js` load, save, import and export an element's
111
+ data. Wire them to your own buttons or menus; they operate on any
112
+ `<data-table>` instance:
113
+
114
+ | Function | Direction | Format |
115
+ | --- | --- | --- |
116
+ | `saveLSS(table, filename)` / `loadLSS(table, file)` | export / import | Native `.lss` (JSON; keeps formulas as `=…` strings; reads older v1/v2 files) |
117
+ | `exportCSV(table, filename)` / `importCSV(table, file)` | export / import | CSV (export writes computed values, like spreadsheet apps) |
118
+ | `exportJSON(table, filename)` / `importJSON(table, file)` | export / import | JSON two-dimensional array |
119
+ | `importSpreadsheetFile(table, file)` | import | XLSX, XLS and ODS |
120
+
121
+ These helpers are the package's only code that uses an npm dependency
122
+ (`xlsx`, for the spreadsheet import); the component itself has none.
123
+
124
+ ### Theming
125
+
126
+ Styles ship as `@lnsy/data-table/data-table.css`, scoped to the component
127
+ (`data-table`, `.dt-*`, `a.wikilink`) so nothing leaks into your page. The
128
+ look is driven by CSS custom properties defined in the package's
129
+ `styles/variables.css` — a "dataroom" theme (warm paper, dark-red accents)
130
+ with an automatic dark scheme via `prefers-color-scheme`. Import that file
131
+ or define your own values for the properties (e.g. `--selection-bg`,
132
+ `--grid-cell-height`) to re-theme the component.
133
+
134
+ Single sheet by design.
135
+
136
+ ## Install
137
+
138
+ ```bash
139
+ npm install @lnsy/data-table
140
+ ```
141
+
142
+ ## Usage
143
+
144
+ Import the module (this registers the `<data-table>` custom element) and the
145
+ stylesheet:
146
+
147
+ ```js
148
+ import '@lnsy/data-table';
149
+ import '@lnsy/data-table/data-table.css';
150
+ ```
151
+
152
+ Then use the element anywhere in your HTML:
153
+
154
+ ```html
155
+ <data-table id="grid" mode="spreadsheet" rows="20" cols="8"></data-table>
156
+ ```
157
+
158
+ The class is also exported for programmatic use:
159
+
160
+ ```js
161
+ import DataTable from '@lnsy/data-table';
162
+ ```
163
+
164
+ ### Attributes
165
+
166
+ | Attribute | Values | Default |
167
+ | ------------------- | ---------------------------------- | -------------- |
168
+ | `mode` | `spreadsheet` \| `table` \| `list` | `spreadsheet` |
169
+ | `read-only` | boolean | off |
170
+ | `rows` / `cols` | integers (spreadsheet mode) | 50 / 12 |
171
+ | `wikilink-base-url` | URL prefix for `[[wikilinks]]` | — |
172
+
173
+ ### Events
174
+
175
+ | Event | Fired when | `detail` |
176
+ | ------------------- | ----------------------------- | ------------------------ |
177
+ | `change` | any cell is edited | `{ row, col, value }` |
178
+ | `selection-change` | the active cell moves | `{ row, col }` |
179
+ | `wikilink-navigate` | a `[[wikilink]]` is clicked | `{ target }` |
180
+
181
+ ### Element API
182
+
183
+ ```js
184
+ const table = document.querySelector('data-table');
185
+
186
+ table.setData([['name', 'qty'], ['nails', '100']]); // replace the contents
187
+ table.getData(); // deep copy of the raw grid
188
+ table.getComputedValue(1, 1); // formula-evaluated cell value
189
+ table.setCellValue(1, 1, '=A1*2');
190
+ table.setEndpoints({ rates: 'https://api.frankfurter.app/latest?from=USD' });
191
+ await table.recalculate(); // re-run every formula and re-render
192
+ table.insertRow(); // append a row (insertRow(5) appends five)
193
+ table.insertColumn(); // append a column
194
+ table.clearSelection(); // blank every cell in the selection
195
+ table.selectionToTSV(); // TSV of the current selection
196
+ table.pasteText('a\tb\nc\td'); // write a TSV block at the active cell
197
+ ```
198
+
199
+ ## Development
200
+
201
+ This repository is the package's development home; the npm package itself
202
+ only exports `<data-table>` (see `src/index.js`). The demo pages, command
203
+ panel wiring and file menus live in the root `index.js` and are not part of
204
+ the published package.
205
+
206
+ ```bash
207
+ npm install # install dependencies
208
+ npm start # dev server on http://localhost:3000
209
+ npm run build # production bundle in dist/
210
+ npm run test:unit # vitest unit tests (tests/unit/)
211
+ npm test # playwright end-to-end tests (tests/e2e/)
212
+ ```
213
+
214
+ Demo pages: `demo.html` links to one demo per feature (endpoints, wikilinks,
215
+ copy-paste, file I/O, table/list modes, live chart).
216
+
217
+ ## Publishing
218
+
219
+ ```bash
220
+ npm version patch # or minor / major
221
+ npm publish # prepublishOnly builds and runs the unit tests
222
+ ```
223
+
224
+ Verify the package contents with `npm pack --dry-run`: only `src/`,
225
+ `styles/`, `README.md` and `LICENSE` ship.
226
+
227
+ ## License
228
+
229
+ [Unlicense](LICENSE) — public domain.
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@lnsy/data-table",
3
+ "version": "0.1.0",
4
+ "description": "A dependency-free <data-table> web component: spreadsheet, table and list modes with formulas, endpoint lookups, wikilinks and file I/O.",
5
+ "main": "src/index.js",
6
+ "module": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./data-table.css": "./src/data-table.css"
10
+ },
11
+ "type": "module",
12
+ "author": "Lindsey Mysse <lindsey@example.com>",
13
+ "license": "Unlicense",
14
+ "keywords": [
15
+ "data-table",
16
+ "spreadsheet",
17
+ "custom-elements",
18
+ "web-component",
19
+ "vanilla-js",
20
+ "grid",
21
+ "formulas"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/lnsy-dev/data-table.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/lnsy-dev/data-table/issues"
32
+ },
33
+ "homepage": "https://github.com/lnsy-dev/data-table#readme",
34
+ "files": [
35
+ "src/",
36
+ "styles/",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "test": "playwright test",
42
+ "test:ui": "playwright test --ui",
43
+ "test:prod": "npm run build && playwright test --project=production",
44
+ "test:unit": "vitest run",
45
+ "test:mutation": "stryker run",
46
+ "start": "webpack serve",
47
+ "build": "NODE_ENV=production webpack build",
48
+ "prepublishOnly": "npm run build && npm run test:unit"
49
+ },
50
+ "dependencies": {
51
+ "xlsx": "^0.18.5"
52
+ },
53
+ "devDependencies": {
54
+ "@lnsy/charts": "file:../charts",
55
+ "@lnsy/command-panel": "^0.1.0",
56
+ "@playwright/test": "^1.62.1",
57
+ "@stryker-mutator/core": "^9.6.1",
58
+ "@stryker-mutator/vitest-runner": "^9.6.1",
59
+ "@swc/core": "^1.9.3",
60
+ "copy-webpack-plugin": "^14.0.0",
61
+ "css-loader": "^7.1.2",
62
+ "cssnano": "^7.1.2",
63
+ "dotenv": "^17.2.2",
64
+ "happy-dom": "^20.14.0",
65
+ "html-webpack-plugin": "^5.6.3",
66
+ "mini-css-extract-plugin": "^2.9.2",
67
+ "postcss": "^8.5.6",
68
+ "postcss-loader": "^8.2.0",
69
+ "serve": "^14.2.6",
70
+ "style-loader": "^4.0.0",
71
+ "swc-loader": "^0.2.6",
72
+ "vitest": "^3.2.7",
73
+ "webpack": "^5.98.0",
74
+ "webpack-cli": "^6.0.1",
75
+ "webpack-dev-server": "^5.2.3"
76
+ }
77
+ }
@@ -0,0 +1,327 @@
1
+ /*
2
+
3
+ <data-table> component stylesheet.
4
+
5
+ Ships with the @lnsy/data-table npm package. Everything here is scoped to
6
+ the component (data-table, .dt-*, a.wikilink) and the body classes it sets
7
+ during column/row resizing.
8
+
9
+ The rules consume the theme custom properties from the aaron-rss
10
+ dataroom theme (--border, --surface, --surface-alt, --muted, --accent,
11
+ --secondary, --font-ui, --font-mono, …), which this file imports. Light
12
+ and dark schemes come from variables.css via `prefers-color-scheme`.
13
+ Page-level styles (fonts, body, headings, buttons) stay in the host
14
+ application's CSS.
15
+
16
+ */
17
+
18
+ @import '../styles/variables.css';
19
+
20
+ data-table {
21
+ display: block;
22
+ font-family: var(--font-mono);
23
+ font-size: 0.85rem;
24
+ }
25
+
26
+ data-table:focus-visible {
27
+ outline: 2px solid var(--accent);
28
+ outline-offset: 2px;
29
+ }
30
+
31
+ .dt-root {
32
+ display: flex;
33
+ flex-direction: column;
34
+ gap: 0.5rem;
35
+ }
36
+
37
+ /* ── Formula bar ──────────────────────────────────────────── */
38
+
39
+ .dt-formula-bar {
40
+ display: flex;
41
+ align-items: stretch;
42
+ gap: 0;
43
+ border: 1px solid var(--border);
44
+ background: var(--background-light);
45
+ box-shadow: var(--box-shadow-inset);
46
+ }
47
+
48
+ .dt-cell-ref {
49
+ display: inline-flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ min-width: 4.5rem;
53
+ padding: 0 0.75rem;
54
+ font-family: var(--font-ui);
55
+ font-weight: var(--font-weight-bold);
56
+ color: var(--accent);
57
+ border-right: 1px solid var(--border);
58
+ }
59
+
60
+ .dt-formula-input {
61
+ flex: 1;
62
+ border: none;
63
+ background: transparent;
64
+ color: var(--foreground);
65
+ font-family: var(--font-mono);
66
+ font-size: 0.9rem;
67
+ padding: 0.35rem 0.6rem;
68
+ }
69
+
70
+ .dt-formula-input:focus {
71
+ outline: none;
72
+ }
73
+
74
+ /* ── Toolbar (table/list modes) ───────────────────────────── */
75
+
76
+ .dt-toolbar {
77
+ display: flex;
78
+ align-items: center;
79
+ gap: 0.75rem;
80
+ }
81
+
82
+ .dt-toolbar label {
83
+ font-family: var(--font-ui);
84
+ color: var(--muted);
85
+ letter-spacing: 0.05em;
86
+ }
87
+
88
+ .dt-toolbar input[type='search'] {
89
+ flex: 1;
90
+ max-width: 24rem;
91
+ padding: 0.3rem 0.6rem;
92
+ border: 1px solid var(--border);
93
+ background: var(--background-light);
94
+ box-shadow: var(--box-shadow-inset);
95
+ color: var(--foreground);
96
+ font-family: var(--font-body);
97
+ font-size: 0.85rem;
98
+ }
99
+
100
+ .dt-toolbar input[type='search']:focus {
101
+ outline: none;
102
+ border-color: var(--accent);
103
+ }
104
+
105
+ .dt-count {
106
+ font-family: var(--font-ui);
107
+ font-size: 0.8rem;
108
+ color: var(--muted);
109
+ }
110
+
111
+ /* ── Grid ─────────────────────────────────────────────────── */
112
+
113
+ .dt-grid-scroll {
114
+ overflow: auto;
115
+ border: 1px solid var(--border);
116
+ max-height: 80vh;
117
+ background: var(--surface);
118
+ }
119
+
120
+ table.dt-grid {
121
+ border-collapse: separate;
122
+ border-spacing: 0;
123
+ table-layout: fixed;
124
+ width: max-content;
125
+ min-width: 100%;
126
+ }
127
+
128
+ .dt-grid th,
129
+ .dt-grid td {
130
+ border-right: 1px solid var(--border);
131
+ border-bottom: 1px solid var(--border);
132
+ padding: 0 0.5rem;
133
+ height: var(--grid-cell-height);
134
+ overflow: hidden;
135
+ white-space: nowrap;
136
+ text-overflow: ellipsis;
137
+ }
138
+
139
+ .dt-grid th {
140
+ font-family: var(--font-ui);
141
+ font-weight: var(--font-weight-semibold);
142
+ font-size: 0.78rem;
143
+ letter-spacing: 0.04em;
144
+ text-align: center;
145
+ color: var(--muted);
146
+ background: var(--surface-alt);
147
+ position: sticky;
148
+ user-select: none;
149
+ }
150
+
151
+ .dt-grid thead th {
152
+ top: 0;
153
+ z-index: 2;
154
+ }
155
+
156
+ .dt-grid .dt-row-header {
157
+ left: 0;
158
+ z-index: 1;
159
+ min-width: 3rem;
160
+ }
161
+
162
+ .dt-grid thead th.dt-corner {
163
+ left: 0;
164
+ top: 0;
165
+ z-index: 3;
166
+ min-width: 3rem;
167
+ }
168
+
169
+ .dt-grid td.dt-cell {
170
+ cursor: cell;
171
+ background: var(--surface);
172
+ }
173
+
174
+ .dt-grid tbody tr:nth-child(even) td.dt-cell {
175
+ background: var(--interactive-hover);
176
+ }
177
+
178
+ .dt-grid td.dt-in-range {
179
+ background: var(--selection-bg) !important;
180
+ }
181
+
182
+ .dt-grid td.dt-active {
183
+ outline: 2px solid var(--neutral-1);
184
+ outline-offset: -2px;
185
+ background: var(--surface) !important;
186
+ }
187
+
188
+ .dt-has-formula {
189
+ color: var(--secondary);
190
+ }
191
+
192
+ /* Cell editor */
193
+ .dt-editor {
194
+ width: 100%;
195
+ height: 100%;
196
+ border: none;
197
+ background: var(--background-light);
198
+ color: var(--foreground);
199
+ font-family: inherit;
200
+ font-size: inherit;
201
+ padding: 0;
202
+ margin: 0;
203
+ }
204
+
205
+ .dt-editor:focus {
206
+ outline: none;
207
+ }
208
+
209
+ /* Sortable headers (table mode) */
210
+ th.dt-sortable {
211
+ cursor: pointer;
212
+ }
213
+
214
+ th.dt-sortable:hover {
215
+ color: var(--accent);
216
+ }
217
+
218
+ th.dt-sorted {
219
+ color: var(--accent);
220
+ }
221
+
222
+ /* ── Column & row resizing (spreadsheet mode) ─────────────── */
223
+
224
+ /* Hover state on a resizable edge: accent line + resize cursor. */
225
+ th.dt-col-resize {
226
+ cursor: col-resize;
227
+ }
228
+
229
+ th.dt-row-resize {
230
+ cursor: row-resize;
231
+ }
232
+
233
+ th.dt-col-resize::after,
234
+ th.dt-row-resize::after {
235
+ content: '';
236
+ position: absolute;
237
+ background: var(--accent);
238
+ opacity: 0.55;
239
+ pointer-events: none;
240
+ }
241
+
242
+ th.dt-col-resize::after {
243
+ top: 0;
244
+ right: -3px;
245
+ width: 6px;
246
+ height: 100%;
247
+ }
248
+
249
+ th.dt-row-resize::after {
250
+ left: 0;
251
+ bottom: -3px;
252
+ height: 6px;
253
+ width: 100%;
254
+ }
255
+
256
+ /* Keep the cursor consistent while dragging past the header. */
257
+ body.dt-resizing {
258
+ user-select: none;
259
+ }
260
+
261
+ body.dt-col-resizing,
262
+ body.dt-col-resizing * {
263
+ cursor: col-resize !important;
264
+ }
265
+
266
+ body.dt-row-resizing,
267
+ body.dt-row-resizing * {
268
+ cursor: row-resize !important;
269
+ }
270
+
271
+ /* ── Footer / status ──────────────────────────────────────── */
272
+
273
+ .dt-footer {
274
+ display: flex;
275
+ align-items: center;
276
+ gap: 1rem;
277
+ font-family: var(--font-ui);
278
+ font-size: 0.8rem;
279
+ color: var(--muted);
280
+ }
281
+
282
+ .dt-status {
283
+ color: var(--accent);
284
+ }
285
+
286
+ /* ── Wikilinks inside cells ───────────────────────────────── */
287
+
288
+ a.wikilink {
289
+ color: var(--color-link);
290
+ font-weight: var(--font-weight-bold);
291
+ text-decoration: underline dotted;
292
+ text-decoration-color: var(--neutral-2);
293
+ text-underline-offset: 0.2em;
294
+ cursor: pointer;
295
+ }
296
+
297
+ a.wikilink:hover {
298
+ color: var(--highlight-bright);
299
+ text-decoration-style: solid;
300
+ }
301
+
302
+ /* ── List mode ────────────────────────────────────────────── */
303
+
304
+ ul.dt-list {
305
+ list-style: none;
306
+ padding: 0;
307
+ margin: 0;
308
+ border: 1px solid var(--border);
309
+ background: var(--surface);
310
+ }
311
+
312
+ ul.dt-list li {
313
+ position: relative;
314
+ padding: 0.45rem 1rem 0.45rem 2.2rem;
315
+ border-bottom: 1px solid var(--border);
316
+ }
317
+
318
+ ul.dt-list li:last-child {
319
+ border-bottom: none;
320
+ }
321
+
322
+ ul.dt-list li::before {
323
+ content: '☞';
324
+ position: absolute;
325
+ left: 0.8rem;
326
+ color: var(--color-link);
327
+ }