@philosaether/chipper 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.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +778 -0
  3. package/dist/base.css +1 -0
  4. package/dist/headless.d.ts +16 -0
  5. package/dist/headless.d.ts.map +1 -0
  6. package/dist/headless.js +18 -0
  7. package/dist/index.js +1445 -0
  8. package/dist/src/builder/index.d.ts +82 -0
  9. package/dist/src/builder/index.d.ts.map +1 -0
  10. package/dist/src/builder/predicates.d.ts +18 -0
  11. package/dist/src/builder/predicates.d.ts.map +1 -0
  12. package/dist/src/components/Chip.d.ts +15 -0
  13. package/dist/src/components/Chip.d.ts.map +1 -0
  14. package/dist/src/components/ChipInfoPopup.d.ts +12 -0
  15. package/dist/src/components/ChipInfoPopup.d.ts.map +1 -0
  16. package/dist/src/components/ChipPopup.d.ts +20 -0
  17. package/dist/src/components/ChipPopup.d.ts.map +1 -0
  18. package/dist/src/components/Chipper.d.ts +25 -0
  19. package/dist/src/components/Chipper.d.ts.map +1 -0
  20. package/dist/src/components/Clause.d.ts +13 -0
  21. package/dist/src/components/Clause.d.ts.map +1 -0
  22. package/dist/src/components/Sentence.d.ts +15 -0
  23. package/dist/src/components/Sentence.d.ts.map +1 -0
  24. package/dist/src/components/index.d.ts +12 -0
  25. package/dist/src/components/index.d.ts.map +1 -0
  26. package/dist/src/components/popups/AlternativeCoordinatePopup.d.ts +16 -0
  27. package/dist/src/components/popups/AlternativeCoordinatePopup.d.ts.map +1 -0
  28. package/dist/src/components/popups/KeywordGroupList.d.ts +29 -0
  29. package/dist/src/components/popups/KeywordGroupList.d.ts.map +1 -0
  30. package/dist/src/components/popups/KeywordOrExpressionPopup.d.ts +30 -0
  31. package/dist/src/components/popups/KeywordOrExpressionPopup.d.ts.map +1 -0
  32. package/dist/src/components/popups/MultiSelectPopup.d.ts +20 -0
  33. package/dist/src/components/popups/MultiSelectPopup.d.ts.map +1 -0
  34. package/dist/src/components/popups/NumericInput.d.ts +16 -0
  35. package/dist/src/components/popups/NumericInput.d.ts.map +1 -0
  36. package/dist/src/components/popups/ReferencePopup.d.ts +20 -0
  37. package/dist/src/components/popups/ReferencePopup.d.ts.map +1 -0
  38. package/dist/src/core/actions/set-chip-value.d.ts +17 -0
  39. package/dist/src/core/actions/set-chip-value.d.ts.map +1 -0
  40. package/dist/src/core/actions/set-context.d.ts +18 -0
  41. package/dist/src/core/actions/set-context.d.ts.map +1 -0
  42. package/dist/src/core/actions/set-display-value.d.ts +18 -0
  43. package/dist/src/core/actions/set-display-value.d.ts.map +1 -0
  44. package/dist/src/core/actions/toggle-clause.d.ts +15 -0
  45. package/dist/src/core/actions/toggle-clause.d.ts.map +1 -0
  46. package/dist/src/core/context-resolution.d.ts +36 -0
  47. package/dist/src/core/context-resolution.d.ts.map +1 -0
  48. package/dist/src/core/initialize.d.ts +43 -0
  49. package/dist/src/core/initialize.d.ts.map +1 -0
  50. package/dist/src/core/mode-switching.d.ts +10 -0
  51. package/dist/src/core/mode-switching.d.ts.map +1 -0
  52. package/dist/src/core/reducer.d.ts +16 -0
  53. package/dist/src/core/reducer.d.ts.map +1 -0
  54. package/dist/src/core/resolve-keyword-label.d.ts +7 -0
  55. package/dist/src/core/resolve-keyword-label.d.ts.map +1 -0
  56. package/dist/src/core/serialize.d.ts +34 -0
  57. package/dist/src/core/serialize.d.ts.map +1 -0
  58. package/dist/src/core/state.d.ts +59 -0
  59. package/dist/src/core/state.d.ts.map +1 -0
  60. package/dist/src/core/store.d.ts +23 -0
  61. package/dist/src/core/store.d.ts.map +1 -0
  62. package/dist/src/core/types.d.ts +242 -0
  63. package/dist/src/core/types.d.ts.map +1 -0
  64. package/dist/src/domains/alternative-coordinate.d.ts +110 -0
  65. package/dist/src/domains/alternative-coordinate.d.ts.map +1 -0
  66. package/dist/src/domains/create-domain.d.ts +30 -0
  67. package/dist/src/domains/create-domain.d.ts.map +1 -0
  68. package/dist/src/domains/facades.d.ts +134 -0
  69. package/dist/src/domains/facades.d.ts.map +1 -0
  70. package/dist/src/domains/index.d.ts +14 -0
  71. package/dist/src/domains/index.d.ts.map +1 -0
  72. package/dist/src/domains/keyword-or-expression.d.ts +148 -0
  73. package/dist/src/domains/keyword-or-expression.d.ts.map +1 -0
  74. package/dist/src/domains/multi-select.d.ts +68 -0
  75. package/dist/src/domains/multi-select.d.ts.map +1 -0
  76. package/dist/src/domains/normalize-keywords.d.ts +83 -0
  77. package/dist/src/domains/normalize-keywords.d.ts.map +1 -0
  78. package/dist/src/domains/reference.d.ts +89 -0
  79. package/dist/src/domains/reference.d.ts.map +1 -0
  80. package/dist/src/hooks/SentenceProvider.d.ts +17 -0
  81. package/dist/src/hooks/SentenceProvider.d.ts.map +1 -0
  82. package/dist/src/hooks/context.d.ts +31 -0
  83. package/dist/src/hooks/context.d.ts.map +1 -0
  84. package/dist/src/hooks/index.d.ts +13 -0
  85. package/dist/src/hooks/index.d.ts.map +1 -0
  86. package/dist/src/hooks/useChip.d.ts +21 -0
  87. package/dist/src/hooks/useChip.d.ts.map +1 -0
  88. package/dist/src/hooks/useDisplaySource.d.ts +16 -0
  89. package/dist/src/hooks/useDisplaySource.d.ts.map +1 -0
  90. package/dist/src/hooks/useKeyboardNavigation.d.ts +44 -0
  91. package/dist/src/hooks/useKeyboardNavigation.d.ts.map +1 -0
  92. package/dist/src/hooks/usePopup.d.ts +13 -0
  93. package/dist/src/hooks/usePopup.d.ts.map +1 -0
  94. package/dist/src/hooks/useReferenceDisplay.d.ts +22 -0
  95. package/dist/src/hooks/useReferenceDisplay.d.ts.map +1 -0
  96. package/dist/src/hooks/useSentence.d.ts +14 -0
  97. package/dist/src/hooks/useSentence.d.ts.map +1 -0
  98. package/dist/src/index.d.ts +25 -0
  99. package/dist/src/index.d.ts.map +1 -0
  100. package/dist/src/palette/index.d.ts +29 -0
  101. package/dist/src/palette/index.d.ts.map +1 -0
  102. package/dist/src/themes/apply-theme.d.ts +34 -0
  103. package/dist/src/themes/apply-theme.d.ts.map +1 -0
  104. package/dist/src/themes/create-hue.d.ts +23 -0
  105. package/dist/src/themes/create-hue.d.ts.map +1 -0
  106. package/dist/src/themes/index.d.ts +13 -0
  107. package/dist/src/themes/index.d.ts.map +1 -0
  108. package/dist/src/themes/midnight.d.ts +11 -0
  109. package/dist/src/themes/midnight.d.ts.map +1 -0
  110. package/dist/src/themes/praxis.d.ts +11 -0
  111. package/dist/src/themes/praxis.d.ts.map +1 -0
  112. package/dist/src/themes/terminal.d.ts +9 -0
  113. package/dist/src/themes/terminal.d.ts.map +1 -0
  114. package/dist/src/themes/types.d.ts +72 -0
  115. package/dist/src/themes/types.d.ts.map +1 -0
  116. package/dist/styles.css +1 -0
  117. package/dist/themes/index.js +256 -0
  118. package/dist/themes/midnight.css +1 -0
  119. package/dist/themes/praxis.css +1 -0
  120. package/dist/themes/terminal.css +1 -0
  121. package/dist/usePopup-Of6OHa1_.js +653 -0
  122. package/package.json +75 -0
package/README.md ADDED
@@ -0,0 +1,778 @@
1
+ # Chipper
2
+
3
+ Plain-English editing interfaces for complex configuration.
4
+
5
+ Chipper is a React library that lets users build structured data by clicking
6
+ semantic chips arranged in readable sentences. Each chip is an interactive
7
+ input — keywords, text fields, number steppers, date pickers, multi-selects —
8
+ but the sentence reads like natural language.
9
+
10
+ > Every **[2]** **[weeks]** on **[tuesday]**, create a task named **[review accounts]**.
11
+
12
+ ```
13
+ npm install chipper
14
+ ```
15
+
16
+ ## Quick Start
17
+
18
+ ```tsx
19
+ import { Chipper, sentence, builder, extendPalette, keywordDomain } from 'chipper';
20
+ import 'chipper/styles.css';
21
+
22
+ const palette = extendPalette({
23
+ chips: {
24
+ priority: keywordDomain({
25
+ color: 'rose',
26
+ keywords: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }],
27
+ }),
28
+ },
29
+ });
30
+
31
+ const mySentence = sentence(palette)
32
+ .clause('main', builder()
33
+ .text('Set priority to')
34
+ .chip('priority')
35
+ .text('.')
36
+ )
37
+ .build();
38
+
39
+ function App() {
40
+ return (
41
+ <Chipper
42
+ sentence={mySentence}
43
+ onChange={(state) => console.log(state)}
44
+ />
45
+ );
46
+ }
47
+ ```
48
+
49
+ That's the minimum: a palette with one domain, a sentence with one clause,
50
+ and a `<Chipper>` component to render it.
51
+
52
+ ## Core Concepts
53
+
54
+ ### Sentence
55
+
56
+ One complete unit of input. Built with the `sentence()` builder, which
57
+ accepts a palette and chains `.clause()` calls:
58
+
59
+ ```ts
60
+ const mySentence = sentence(palette)
61
+ .clause('first', builder().text('Do').chip('action'))
62
+ .clause('second', builder().text('at').chip('time'))
63
+ .build();
64
+ ```
65
+
66
+ ### Clause
67
+
68
+ A fragment of a sentence containing text and chips. Built with `builder()`.
69
+ Clauses can be required (default), optional (user-toggled), or contingent
70
+ (shown/hidden by the engine based on other chip values).
71
+
72
+ ### Chip
73
+
74
+ An interactive input within a clause. Added with `.chip('id')`. The chip
75
+ ID maps to a domain name in the palette — when they match, you only need
76
+ the ID:
77
+
78
+ ```ts
79
+ builder().text('Pick a').chip('color') // looks up 'color' domain in palette
80
+ ```
81
+
82
+ ### Domain
83
+
84
+ Defines a chip's value space: what values are valid, how they display in
85
+ the chip trigger, and what popup UI appears when the user clicks.
86
+
87
+ ### Palette
88
+
89
+ Maps domain names to domain instances. Created with `extendPalette()`:
90
+
91
+ ```ts
92
+ const palette = extendPalette({
93
+ chips: {
94
+ color: keywordDomain({ color: 'sage', keywords: [{ value: 'red' }, { value: 'blue' }] }),
95
+ name: textDomain({ color: 'rose', placeholder: 'a name' }),
96
+ },
97
+ });
98
+ ```
99
+
100
+ ### Line
101
+
102
+ Visual grouping. Clauses after `.line()` render on a new row. Lines with
103
+ all-optional or all-contingent clauses auto-indent:
104
+
105
+ ```ts
106
+ sentence(palette)
107
+ .clause('trigger', builder().text('Every').chip('cadence').produces('cadence'))
108
+ .line()
109
+ .clause('detail', builder()
110
+ .optional()
111
+ .text('at')
112
+ .chip('time')
113
+ )
114
+ .build();
115
+ ```
116
+
117
+ ## Domain Types
118
+
119
+ ### Simple Domains
120
+
121
+ These cover the most common chip types. Each is a thin wrapper over the
122
+ engine — start here.
123
+
124
+ #### `keywordDomain(config)`
125
+
126
+ Fixed set of options. The user clicks one.
127
+
128
+ ```ts
129
+ keywordDomain({
130
+ color: 'sage',
131
+ keywords: [
132
+ { value: 'low' },
133
+ { value: 'medium' },
134
+ { value: 'high', label: 'High Priority' },
135
+ ],
136
+ default: 'medium', // optional — defaults to first keyword
137
+ placeholder: 'a level', // optional — shown when value is invalid
138
+ })
139
+ ```
140
+
141
+ - `color` — semantic color key (maps to `--chipper-color-{key}-*` CSS properties)
142
+ - `keywords` — array of `{ value, label?, display? }`. `label` defaults to
143
+ `value`. `display` (shown on the chip trigger) defaults to `label`.
144
+ - `default` — initial value. Defaults to first keyword.
145
+ - `placeholder` — chip trigger text when value is invalid.
146
+
147
+ #### `textDomain(config)`
148
+
149
+ Free-text input. The user types a value.
150
+
151
+ ```ts
152
+ textDomain({
153
+ color: 'rose',
154
+ placeholder: 'a task name',
155
+ maxLength: 200, // default 140
156
+ })
157
+ ```
158
+
159
+ - `maxLength` — character limit (default 140)
160
+ - `validate` — custom validation function beyond non-empty
161
+ - `display` — format the value for the chip trigger
162
+ - `keywords` — optional preset values shown as pills above the text input
163
+
164
+ #### `numberDomain(config)`
165
+
166
+ Numeric input with a stepper UI (+/- buttons).
167
+
168
+ ```ts
169
+ numberDomain({
170
+ color: 'copper',
171
+ min: 1,
172
+ max: 365,
173
+ step: 1, // default 1
174
+ suffix: 'days', // shown after the value
175
+ placeholder: 'a number',
176
+ })
177
+ ```
178
+
179
+ - `min`, `max`, `step` — stepper bounds
180
+ - `prefix`, `suffix` — text flanking the input. Can be static strings or
181
+ context-aware functions: `suffix: (ctx) => ctx.unit + 's'`
182
+ - `keywords` — optional preset values
183
+
184
+ #### `dateDomain(config)`
185
+
186
+ Calendar date picker. Values are `YYYY-MM-DD` strings.
187
+
188
+ ```ts
189
+ dateDomain({
190
+ color: 'sage',
191
+ keywords: [
192
+ { value: 'tomorrow', label: 'tomorrow' },
193
+ { value: 'next-monday', label: 'next Monday' },
194
+ ],
195
+ placeholder: 'a date',
196
+ })
197
+ ```
198
+
199
+ - `validate` — custom validation beyond YYYY-MM-DD format
200
+ - `display` — format the date for the chip trigger
201
+ - `keywords` — preset date shortcuts
202
+
203
+ ### Power-User Domains
204
+
205
+ #### `keywordOrExpressionDomain(config)`
206
+
207
+ Keywords plus optional freeform expression input. The simple domains
208
+ delegate to this internally — use it directly when you need:
209
+
210
+ - **Trigger-gated expression**: a keyword that reveals the freeform input
211
+ - **Context-aware labels**: keyword labels that change based on other chips
212
+ - **Full expression config**: custom input types, prefix/suffix, validation
213
+
214
+ ```ts
215
+ keywordOrExpressionDomain({
216
+ color: 'copper',
217
+ keywords: [
218
+ { value: 'daily', label: 'day' },
219
+ { value: 'weekly', label: 'week' },
220
+ ],
221
+ expression: numericExpression({
222
+ min: 1,
223
+ max: 365,
224
+ trigger: { label: 'custom interval', default: '2' },
225
+ }),
226
+ default: 'weekly',
227
+ })
228
+ ```
229
+
230
+ The `trigger` option hides the expression input until the user clicks
231
+ "custom interval". Without a trigger, the input is always visible.
232
+
233
+ Expression helpers: `textExpression()`, `numericExpression()`,
234
+ `dateExpression()` — sugar for building `ExpressionConfig` objects.
235
+
236
+ #### `multiSelectDomain(config)`
237
+
238
+ Toggle grid for selecting multiple values. The chip displays selected
239
+ items (up to 3, then a count).
240
+
241
+ ```ts
242
+ multiSelectDomain({
243
+ color: 'sage',
244
+ options: [
245
+ { label: 'Mon', value: 'mon' },
246
+ { label: 'Tue', value: 'tue' },
247
+ // ...
248
+ ],
249
+ keywords: [
250
+ { label: 'weekdays', value: ['mon', 'tue', 'wed', 'thu', 'fri'] },
251
+ ],
252
+ placeholder: 'one or more days',
253
+ countLabel: 'days',
254
+ })
255
+ ```
256
+
257
+ - `options` — individual toggle items
258
+ - `keywords` — group shortcuts (selecting "weekdays" toggles all five)
259
+ - `countLabel` — label for "N selected" display (e.g., "3 days")
260
+
261
+ #### `alternativeCoordinateDomain(config)`
262
+
263
+ Tabbed popup with multiple input modes. Each mode has slots that compose
264
+ into a single value.
265
+
266
+ ```ts
267
+ alternativeCoordinateDomain({
268
+ color: 'sage',
269
+ modes: [
270
+ {
271
+ id: 'date',
272
+ label: 'Date',
273
+ slots: [{ prefix: 'the', keywords: [{ label: 'first', value: '1' }] }],
274
+ compose: (day) => day,
275
+ decompose: (v) => [v],
276
+ display: (v) => `the ${v}th`,
277
+ },
278
+ ],
279
+ placeholder: 'a day',
280
+ })
281
+ ```
282
+
283
+ #### `referenceDomain(config)`
284
+
285
+ Hierarchical navigation + search for external data. Supports async sources.
286
+
287
+ ```ts
288
+ referenceDomain({
289
+ color: 'indigo',
290
+ source: {
291
+ getItems: async (path) => fetchCategories(path),
292
+ search: async (query) => searchCategories(query),
293
+ resolveDisplay: async (id) => getCategoryLabel(id),
294
+ },
295
+ placeholder: 'a category',
296
+ })
297
+ ```
298
+
299
+ ## Building Sentences
300
+
301
+ ### Optional Clauses
302
+
303
+ Users can toggle optional clauses on and off. Dormant optional clauses
304
+ render as muted italic text showing their configured values:
305
+
306
+ ```ts
307
+ .clause('detail', builder()
308
+ .optional()
309
+ .text('with priority')
310
+ .chip('priority')
311
+ )
312
+ ```
313
+
314
+ ### Contingent Clauses
315
+
316
+ Clauses that appear or disappear based on other chip values. Use
317
+ `.contingentOn()` with the ID of the clause that produces the context:
318
+
319
+ ```ts
320
+ .clause('trigger', builder()
321
+ .text('Every')
322
+ .chip('cadence')
323
+ .produces('cadence') // makes cadence value available as context
324
+ )
325
+ .clause('weekday', builder()
326
+ .text('on')
327
+ .chip('day')
328
+ .contingentOn('trigger', (ctx) => ctx.cadence === 'weekly')
329
+ )
330
+ ```
331
+
332
+ The `weekday` clause only appears when cadence is "weekly". Context flows
333
+ down the contingency tree — a clause reads context from its superclause
334
+ and all ancestors.
335
+
336
+ **Lambda shorthand**: when you only need a presence predicate (no domain
337
+ reconfiguration), pass a bare function:
338
+
339
+ ```ts
340
+ .contingentOn('trigger', (ctx) => ctx.cadence === 'weekly')
341
+ ```
342
+
343
+ **Object form**: for cases that also need domain reconfiguration:
344
+
345
+ ```ts
346
+ .contingentOn('trigger', {
347
+ present: (ctx) => ctx.cadence === 'weekly',
348
+ configure: (ctx) => ({ keywords: getOptionsFor(ctx.cadence) }),
349
+ })
350
+ ```
351
+
352
+ ### Context Propagation
353
+
354
+ `.produces()` declares what context keys a clause makes available to
355
+ contingent clauses:
356
+
357
+ ```ts
358
+ // String shorthand — clause ID as context key, maps to its chip value
359
+ .produces('cadence')
360
+
361
+ // Object form — explicit mapping
362
+ .produces({ cadenceMeasure: 'cadenceMeasure', cadenceUnit: 'cadenceUnit' })
363
+ ```
364
+
365
+ ### Chip-Level Contingency
366
+
367
+ Individual chips within a clause can be shown/hidden based on context:
368
+
369
+ ```ts
370
+ builder()
371
+ .text('Every')
372
+ .chip('measure')
373
+ .chip('unit', { present: (ctx) => !isNaN(Number(ctx.measure)) })
374
+ ```
375
+
376
+ The `unit` chip only appears when `measure` is numeric. Hidden chips are
377
+ excluded from context production.
378
+
379
+ ### Keywords
380
+
381
+ Keywords support several display options:
382
+
383
+ ```ts
384
+ {
385
+ value: 'daily', // the stored value
386
+ label: 'day', // popup pill text (defaults to value)
387
+ display: 'every day', // chip trigger text (defaults to label)
388
+ }
389
+ ```
390
+
391
+ Labels can be context-aware functions:
392
+
393
+ ```ts
394
+ { value: '1', label: (ctx) => `next ${ctx.unit ?? 'month'}` }
395
+ ```
396
+
397
+ ### Keyword Groups
398
+
399
+ Keywords can be organized into visual groups with labels, separators, and
400
+ layout control. Mix plain keywords and groups freely — ungrouped keywords
401
+ collect into an implicit group at the top:
402
+
403
+ ```ts
404
+ keywordDomain({
405
+ color: 'sage',
406
+ keywords: [
407
+ { value: '1', label: '1st' },
408
+ { value: '15', label: '15th' },
409
+ { value: 'last', label: 'last day' },
410
+ {
411
+ label: 'date',
412
+ layout: 'grid',
413
+ columns: 7,
414
+ keywords: Array.from({ length: 31 }, (_, i) => ({
415
+ value: String(i + 1),
416
+ label: String(i + 1),
417
+ })),
418
+ },
419
+ ],
420
+ })
421
+ ```
422
+
423
+ Group options:
424
+
425
+ | Option | Type | Default | Description |
426
+ |--------|------|---------|-------------|
427
+ | `label` | `string` | — | Header text above the group |
428
+ | `keywords` | `KeywordConfig[]` | *required* | Keywords in this group |
429
+ | `layout` | `'flow' \| 'grid'` | `'flow'` | Layout mode |
430
+ | `columns` | `number` | `7` | Grid columns (only with `layout: 'grid'`) |
431
+ | `prefix` | `string` | — | Text before keyword pills (e.g., "the") |
432
+
433
+ Grouping works across all keyword-accepting domains: `keywordDomain`,
434
+ `textDomain`, `numberDomain`, `dateDomain`, `keywordOrExpressionDomain`,
435
+ `multiSelectDomain` (options), and `alternativeCoordinateDomain` (slot keywords).
436
+
437
+ ## Display Chips
438
+
439
+ Not every chip needs user input. **Display chips** show values from
440
+ external sources — fixed strings, derived computations, remote APIs,
441
+ or live subscriptions. Add `display` to any `.chip()` call:
442
+
443
+ ```ts
444
+ // Static value — debugging, scaffolding, or contextually fixed data
445
+ .chip('project', 'projectName', { display: 'Praxis' })
446
+
447
+ // Derived from context — uses the same ctx pattern as contingency lambdas
448
+ .chip('cost', 'currency', {
449
+ display: (ctx) => lookupPrice(ctx.item)
450
+ })
451
+
452
+ // Remote fetch — one-shot or polling
453
+ .chip('weather', 'text', {
454
+ display: { url: '/api/weather', extract: (r: any) => r.temp, interval: 60000 }
455
+ })
456
+
457
+ // External subscription — WebSocket, EventSource, etc.
458
+ .chip('price', 'currency', {
459
+ display: { subscribe: (cb) => stockTicker.on('AAPL', cb) }
460
+ })
461
+ ```
462
+
463
+ Display chips render with no border and a pastel background. They're
464
+ visually distinct from interactive chips — the user can see them but
465
+ can't edit them.
466
+
467
+ ### Info Popup
468
+
469
+ Display chips can show provenance info on click:
470
+
471
+ ```ts
472
+ .chip('elapsed', 'text', {
473
+ display: (ctx) => formatElapsed(new Date('2026-05-15')),
474
+ info: 'Time elapsed since May 15, 2026',
475
+ })
476
+
477
+ // Dynamic info content
478
+ .chip('total', 'currency', {
479
+ display: (ctx) => computeTotal(ctx),
480
+ info: (value, state) => `Sum of ${countItems(state)} line items`,
481
+ })
482
+ ```
483
+
484
+ ### Source Types
485
+
486
+ | Shorthand | Source | Description |
487
+ |-----------|--------|-------------|
488
+ | Primitive (`'Praxis'`, `42`) | `static` | Fixed value, set once |
489
+ | Function (`(ctx) => ...`) | `derived` | Recomputes on state change, receives clause context |
490
+ | `{ url, extract, interval? }` | `remote` | Fetches from URL, optional polling |
491
+ | `{ subscribe }` | `external` | Consumer-managed subscription |
492
+
493
+ ### Visual States
494
+
495
+ | State | Appearance |
496
+ |-------|-----------|
497
+ | Normal | Pastel background, no border |
498
+ | Loading | Subtle pulse animation |
499
+ | Error | Error-colored border |
500
+ | Info open | Accent glow (same as expanded interactive chips) |
501
+
502
+ ### Serialization
503
+
504
+ Static display chips are included in serialized output (they hold real
505
+ values). Derived, remote, and external display chips are excluded — their
506
+ values are ephemeral.
507
+
508
+ ## Reading State
509
+
510
+ The `onChange` callback receives a `SentenceState` on every change:
511
+
512
+ ```ts
513
+ <Chipper sentence={mySentence} onChange={(state) => {
514
+ // state.valid — is the entire sentence valid?
515
+ // state.clauses — keyed by clause ID
516
+ // .active — is this clause currently shown?
517
+ // .valid — are all chips in this clause valid?
518
+ // .chips — keyed by chip ID
519
+ // .value — the current value
520
+ // .displayValue — formatted for display
521
+ // .valid — does the value pass domain validation?
522
+ // .dirty — has the user changed this chip?
523
+ }} />
524
+ ```
525
+
526
+ ## Theming
527
+
528
+ Chipper's appearance is controlled by CSS custom properties with the
529
+ `--chipper-*` prefix. Override them to match your app:
530
+
531
+ ```css
532
+ :root {
533
+ --chipper-bg-primary: #1a1b2e;
534
+ --chipper-text-primary: #e0e0ef;
535
+ --chipper-accent: #7b9fd4;
536
+ }
537
+ ```
538
+
539
+ ### Chip Colors
540
+
541
+ Each chip gets a semantic color via the `color` config field. Colors map
542
+ to three CSS properties:
543
+
544
+ ```css
545
+ --chipper-color-{name}-text /* chip text color */
546
+ --chipper-color-{name}-bg /* chip background */
547
+ --chipper-color-{name}-hover /* chip hover state */
548
+ ```
549
+
550
+ The default theme (praxis) provides: copper, sage, slate, stone, teal,
551
+ rose, umber, plum, indigo.
552
+
553
+ ### Font
554
+
555
+ Chipper inherits the consumer's font by default. Override with
556
+ `--chipper-font`.
557
+
558
+ ## Headless Mode
559
+
560
+ Import from `chipper/headless` for hooks without components:
561
+
562
+ ```ts
563
+ import { SentenceProvider, useSentence, useChip, usePopup } from 'chipper/headless';
564
+ ```
565
+
566
+ Wrap your custom UI in `SentenceProvider`:
567
+
568
+ ```tsx
569
+ <SentenceProvider definition={mySentence} onChange={handleChange}>
570
+ <MyCustomSentenceUI />
571
+ </SentenceProvider>
572
+ ```
573
+
574
+ Then use hooks inside:
575
+
576
+ - `useSentence()` — sentence-level state, dispatch, definition, resolved domains
577
+ - `useChip(clauseId, chipId)` — chip state + `setValue` function
578
+ - `usePopup()` — singleton popup state: `open()`, `close()`, `isOpen()`
579
+
580
+ ## `<Chipper>` Component
581
+
582
+ The main entry point. Wraps `SentenceProvider` + `Sentence`:
583
+
584
+ ```tsx
585
+ <Chipper
586
+ sentence={mySentence} // SentenceDefinition from .build()
587
+ onChange={(state) => {}} // called on every state change
588
+ />
589
+ ```
590
+
591
+ ## API Reference
592
+
593
+ ### Domain Factories
594
+
595
+ #### `keywordDomain(config)` — Fixed keyword set
596
+
597
+ | Option | Type | Default | Description |
598
+ |--------|------|---------|-------------|
599
+ | `color` | `string` | *required* | Semantic color key |
600
+ | `keywords` | `KeywordGroupItem[]` | *required* | Values: `{ value, label?, display? }` or groups: `{ label?, keywords, layout?, columns?, prefix? }` |
601
+ | `default` | `string` | first keyword | Initial value |
602
+ | `placeholder` | `string` | — | Chip text when value is invalid |
603
+
604
+ #### `textDomain(config)` — Free-text input
605
+
606
+ | Option | Type | Default | Description |
607
+ |--------|------|---------|-------------|
608
+ | `color` | `string` | *required* | Semantic color key |
609
+ | `placeholder` | `string` | — | Chip text when empty |
610
+ | `default` | `string` | `''` | Initial value |
611
+ | `maxLength` | `number` | `140` | Character limit |
612
+ | `validate` | `(v: string) => boolean` | non-empty | Custom validation |
613
+ | `display` | `(v: string) => string` | identity | Format value for chip trigger |
614
+ | `keywords` | `KeywordConfig[]` | — | Optional preset pills |
615
+
616
+ #### `numberDomain(config)` — Numeric stepper
617
+
618
+ | Option | Type | Default | Description |
619
+ |--------|------|---------|-------------|
620
+ | `color` | `string` | *required* | Semantic color key |
621
+ | `placeholder` | `string` | — | Chip text when empty |
622
+ | `default` | `string` | `''` | Initial value |
623
+ | `min` | `number` | — | Minimum value |
624
+ | `max` | `number` | — | Maximum value |
625
+ | `step` | `number` | `1` | Stepper increment |
626
+ | `prefix` | `string \| (ctx) => string` | — | Text before input |
627
+ | `suffix` | `string \| (ctx) => string` | — | Text after input |
628
+ | `validate` | `(v: string) => boolean` | numeric check | Custom validation |
629
+ | `display` | `(v: string) => string` | identity | Format value for chip trigger |
630
+ | `keywords` | `KeywordConfig[]` | — | Optional preset pills |
631
+
632
+ #### `dateDomain(config)` — Calendar date picker
633
+
634
+ | Option | Type | Default | Description |
635
+ |--------|------|---------|-------------|
636
+ | `color` | `string` | *required* | Semantic color key |
637
+ | `placeholder` | `string` | — | Chip text when empty |
638
+ | `default` | `string` | `''` | Initial value |
639
+ | `validate` | `(v: string) => boolean` | YYYY-MM-DD | Custom validation |
640
+ | `display` | `(v: string) => string` | identity | Format date for chip trigger |
641
+ | `keywords` | `KeywordConfig[]` | — | Optional date presets |
642
+
643
+ #### `keywordOrExpressionDomain(config)` — Keywords + freeform expression
644
+
645
+ | Option | Type | Default | Description |
646
+ |--------|------|---------|-------------|
647
+ | `color` | `string` | *required* | Semantic color key |
648
+ | `keywords` | `KeywordGroupItem[]` | `[]` | Preset values (plain or grouped) |
649
+ | `expression` | `ExpressionConfig` | — | Freeform input config (omit for keywords-only) |
650
+ | `default` | `string` | first keyword or `''` | Initial value |
651
+ | `placeholder` | `string` | — | Chip text when value is invalid |
652
+ | `consumes` | `string[]` | — | Context keys read from ancestors |
653
+ | `produces` | `string[]` | — | Context keys written for descendants |
654
+ | `onContextChange` | `(ctx) => Partial<Domain>` | — | Reconfigure when context changes |
655
+
656
+ #### `expressionDomain(config)` — Expression-only (no keywords)
657
+
658
+ Same as `keywordOrExpressionDomain` but `expression` is required and
659
+ `keywords` is always empty.
660
+
661
+ #### `multiSelectDomain(config)` — Toggle grid
662
+
663
+ | Option | Type | Default | Description |
664
+ |--------|------|---------|-------------|
665
+ | `color` | `string` | *required* | Semantic color key |
666
+ | `options` | `KeywordGroupItem[]` | *required* | Individual toggle items (plain or grouped) |
667
+ | `keywords` | `{ label, value: string[] }[]` | `[]` | Group shortcuts |
668
+ | `default` | `string[]` | `[]` | Initially selected values |
669
+ | `placeholder` | `string` | — | Chip text when empty |
670
+ | `maxSelections` | `number` | — | Cap on selected items |
671
+ | `countLabel` | `string` | `'selected'` | Label for "N selected" display |
672
+
673
+ #### `alternativeCoordinateDomain(config)` — Tabbed multi-mode
674
+
675
+ | Option | Type | Default | Description |
676
+ |--------|------|---------|-------------|
677
+ | `color` | `string` | *required* | Semantic color key |
678
+ | `modes` | `AlternativeCoordinateMode[]` | *required* | Tab definitions with slots, compose, decompose, display |
679
+ | `default` | `string` | — | Initial composed value |
680
+ | `placeholder` | `string` | — | Chip text when value is invalid |
681
+
682
+ #### `referenceDomain(config)` — Hierarchical navigation + search
683
+
684
+ | Option | Type | Default | Description |
685
+ |--------|------|---------|-------------|
686
+ | `color` | `string` | *required* | Semantic color key |
687
+ | `source` | `ReferenceSource` | *required* | `{ getItems, search, resolveDisplay }` |
688
+ | `keywords` | `KeywordConfig[]` | `[]` | Static shortcut values |
689
+ | `default` | `string` | `''` | Initial value |
690
+ | `placeholder` | `string` | — | Chip text when value is invalid |
691
+
692
+ ### Expression Helpers
693
+
694
+ #### `textExpression(options?)`
695
+
696
+ | Option | Type | Default | Description |
697
+ |--------|------|---------|-------------|
698
+ | `placeholder` | `string` | — | Input placeholder text |
699
+ | `maxLength` | `number` | — | Character limit |
700
+ | `validate` | `(v: string) => boolean` | non-empty | Custom validation |
701
+ | `display` | `(v: string) => string` | identity | Format for chip trigger |
702
+ | `prefix` | `string \| (ctx) => string` | — | Text before input |
703
+ | `suffix` | `string \| (ctx) => string` | — | Text after input |
704
+ | `position` | `'above' \| 'below'` | `'below'` | Input placement relative to keywords |
705
+ | `trigger` | `{ label, default }` | — | Keyword that reveals the input |
706
+
707
+ #### `numericExpression(options?)`
708
+
709
+ Same options as `textExpression`, plus:
710
+
711
+ | Option | Type | Default | Description |
712
+ |--------|------|---------|-------------|
713
+ | `min` | `number` | — | Minimum value |
714
+ | `max` | `number` | — | Maximum value |
715
+ | `step` | `number` | `1` | Stepper increment |
716
+
717
+ Default `validate` rejects empty strings and non-numeric values.
718
+
719
+ #### `dateExpression(options?)`
720
+
721
+ Same options as `textExpression`. Default `validate` checks YYYY-MM-DD
722
+ format and calendar validity.
723
+
724
+ ### Builder
725
+
726
+ | Function | Description |
727
+ |----------|-------------|
728
+ | `sentence(palette?)` | Start building a sentence |
729
+ | `builder()` | Start building a clause |
730
+ | `chip(id, domainName?, options?)` | Standalone chip definition |
731
+ | `extendPalette(config)` | Create a palette with domain mappings |
732
+
733
+ #### Sentence Builder
734
+
735
+ | Method | Description |
736
+ |--------|-------------|
737
+ | `.clause(id, builder)` | Add a clause |
738
+ | `.line(options?)` | Start a new visual line |
739
+ | `.build()` | Return the `SentenceDefinition` |
740
+
741
+ #### Clause Builder
742
+
743
+ | Method | Description |
744
+ |--------|-------------|
745
+ | `.text(value)` | Add a text segment |
746
+ | `.chip(id, options?)` | Add a chip segment |
747
+ | `.optional()` | Make clause user-toggleable |
748
+ | `.contingentOn(id, config)` | Make clause context-dependent |
749
+ | `.produces(mapping)` | Declare context keys this clause produces |
750
+ | `.placeholder(text)` | Dormant clause display text |
751
+
752
+ ### Hooks (headless)
753
+
754
+ | Hook | Description |
755
+ |------|-------------|
756
+ | `useSentence()` | Sentence state, dispatch, definition, domains |
757
+ | `useChip(clauseId, chipId)` | Chip state + `setValue` |
758
+ | `usePopup()` | Singleton popup: `open`, `close`, `isOpen` |
759
+
760
+ ### Components
761
+
762
+ | Component | Description |
763
+ |-----------|-------------|
764
+ | `<Chipper>` | Auto-rendering entry point (`sentence` + `onChange`) |
765
+ | `<SentenceProvider>` | Context provider for headless mode |
766
+ | `<Sentence>` | Renders clauses grouped by lines |
767
+ | `<Clause>` | Renders text + chips for one clause |
768
+ | `<Chip>` | Trigger button + popup mount |
769
+
770
+ ## Example
771
+
772
+ See [`demo/src/App.tsx`](demo/src/App.tsx) for a full working example
773
+ with contingent clauses, context propagation, multiple domain types,
774
+ theme switching, and multi-line sentences.
775
+
776
+ ## License
777
+
778
+ MIT