@kolkrabbi/kol-component 0.100.1 → 0.102.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.100.1",
3
+ "version": "0.102.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -137,12 +137,13 @@ export { default as LoaderOverlay } from './utilities/LoaderOverlay.jsx'
137
137
  export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPicker, MediaBrowser } from './organisms/MediaLibrary.jsx'
138
138
  export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
139
139
  export { default as MediaViewer } from './organisms/MediaViewer.jsx'
140
- export { default as SettingsPanel, SettingsRow, SettingsSwitch, SettingsChoice, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
140
+ export { default as SettingsPanel, SettingsSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
141
141
  export { default as SectionNewsletter } from './organisms/SectionNewsletter.jsx'
142
142
  export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
143
143
  export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
144
144
  export { default as KindPreview } from './molecules/KindPreview.jsx'
145
145
  export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL } from './utilities/mediaKinds.js'
146
+ export { default as markdownToHtml, inlineToHtml } from './utilities/markdownToHtml.js'
146
147
  export { default as RecordManager } from './organisms/RecordManager.jsx'
147
148
  export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
148
149
  export { default as Table } from './organisms/Table.jsx'
@@ -4,6 +4,7 @@ import HlsVideo from '../atoms/HlsVideo.jsx'
4
4
  import CodeBlock from './CodeBlock.jsx'
5
5
  import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
6
6
  import { kindOf as defaultKindOf, extOf as defaultExtOf, KIND_LABEL } from '../utilities/mediaKinds.js'
7
+ import markdownToHtml from '../utilities/markdownToHtml.js'
7
8
 
8
9
  /* taxonomy-ok: molecule — nests the DS media atoms + CodeBlock (relative). */
9
10
 
@@ -12,8 +13,9 @@ import { kindOf as defaultKindOf, extOf as defaultExtOf, KIND_LABEL } from '../u
12
13
  * promoted 2026-08-27 (SettingsPanelChromeAndColumnPreview). Before it, anything
13
14
  * that was not an image or a video showed a grey box with the word "text".
14
15
  * HLS → `HlsVideo` (inert — the DS's background-video atom, preview only),
15
- * audio → `AudioPlayer`, text / code `CodeBlock` (language by extension;
16
- * markdown as code the DS has no markdown renderer in this tier), the rest
16
+ * audio → `AudioPlayer`, markdownrendered prose in `.kol-prose`
17
+ * (markdownToHtml, KindPreviewMarkdown 2026-08-27), json / yaml / text / code
18
+ * `CodeBlock` (language by kind or extension), the rest
17
19
  * → `AssetPlaceholder`. Images are the caller's (ColumnBrowser keeps its own
18
20
  * `<img>` so it can read the dimensions). Text fetches cap at `textLimit`.
19
21
  *
@@ -58,7 +60,7 @@ export default function KindPreview({ o, urlOf = (x) => x.url, poster, kindOf =
58
60
  const url = urlOf(o)
59
61
  const ext = extOf(o.key)
60
62
  const name = o.displayKey ?? o.key
61
- const isText = kind === 'text' || kind === 'code'
63
+ const isText = kind === 'text' || kind === 'code' || kind === 'markdown' || kind === 'json' || kind === 'yaml'
62
64
  const { loading, text, error, truncated } = useTextContent(url, isText, textLimit)
63
65
 
64
66
  if (kind === 'playlist') {
@@ -79,9 +81,18 @@ export default function KindPreview({ o, urlOf = (x) => x.url, poster, kindOf =
79
81
  if (isText) {
80
82
  if (loading) return <span className="kol-mono-12 text-meta">Loading…</span>
81
83
  if (error) return <span className="kol-mono-12 text-ui-error">Couldn’t load: {error}</span>
84
+ if (kind === 'markdown') {
85
+ return (
86
+ <div className="max-w-[70ch] max-h-[78vh] overflow-y-auto">
87
+ <div className="kol-prose" dangerouslySetInnerHTML={{ __html: markdownToHtml(text) }} />
88
+ {truncated && <p className="kol-mono-12 text-meta">truncated at {textLimit / 1024} KB</p>}
89
+ </div>
90
+ )
91
+ }
92
+ const language = kind === 'json' ? 'json' : kind === 'yaml' ? 'yaml' : LANG[ext] || 'text'
82
93
  return (
83
94
  <div className="max-w-[80ch] max-h-[78vh] overflow-y-auto">
84
- <CodeBlock code={text} language={LANG[ext] || 'text'} filename={name} />
95
+ <CodeBlock code={text} language={language} filename={name} />
85
96
  {truncated && <p className="kol-mono-12 text-meta">truncated at {textLimit / 1024} KB</p>}
86
97
  </div>
87
98
  )
@@ -1,5 +1,6 @@
1
1
  import Button from '../atoms/Button.jsx'
2
- import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
2
+ import Dropdown from '../molecules/Dropdown.jsx'
3
+ import SectionLabel from '../atoms/SectionLabel.jsx'
3
4
  import ToggleSwitch from '../atoms/ToggleSwitch.jsx'
4
5
  import ShellDrawer from '../molecules/ShellDrawer.jsx'
5
6
  import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
@@ -21,7 +22,7 @@ import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
21
22
  * where the DS already owned one.
22
23
  *
23
24
  * The CONTROLS are the DS controls, not the source's word-buttons: a switch
24
- * row is `ToggleSwitch`, a choice row is `SegmentedToggle` — the source's
25
+ * row is `ToggleSwitch`, a choice row is a `Dropdown` (2026-08-27) — the source's
25
26
  * segmented variant was flagged by the user as the thing to fix here, not to
26
27
  * copy. Sections are the DS `Section` (pass `divided` on every one and the
27
28
  * hairline lands between siblings on its own).
@@ -129,11 +130,26 @@ export function SettingsSwitch({ on = false, onChange, disabled = false, disable
129
130
  )
130
131
  }
131
132
 
132
- /** SettingsChoice — the row's one-of-N control: the DS SegmentedToggle, sm.
133
- * Options are values or `{ value, label }`. */
134
- export function SettingsChoice({ options = [], value, onChange, ariaLabel }) {
133
+ /** SettingsSectiona section of the panel: the estate's section label (the DS
134
+ * `SectionLabel`, sm SettingsPanelEyebrowAndDropdowns, user 2026-08-27: "it's
135
+ * literally SECTION LABEL, SUPER COMMON PATTERN") over its rows.
136
+ * @param {string} label · @param {boolean} divided a hairline above (between sections) */
137
+ export function SettingsSection({ label, divided = false, children, className = '' }) {
138
+ return (
139
+ <div className={`flex flex-col gap-3 ${divided ? 'kol-section--divided' : ''} ${className}`.replace(/\s+/g, ' ').trim()}>
140
+ {label && <SectionLabel text={label} size="sm" />}
141
+ {children}
142
+ </div>
143
+ )
144
+ }
145
+
146
+ /** SettingsChoice — the row's one-of-N control: the DS Dropdown, sm · primary
147
+ * (SettingsPanelEyebrowAndDropdowns — user: "put the toggles inside a dropdown,
148
+ * because it's super messy like it is"). Options are values or `{ value, label }`;
149
+ * width is the call site's (`className="w-40"`). */
150
+ export function SettingsChoice({ options = [], value, onChange, ariaLabel, className = '' }) {
135
151
  const opts = options.map((o) => (o != null && typeof o === 'object' ? o : { value: o, label: String(o) }))
136
- return <SegmentedToggle size="sm" value={value} onChange={onChange} options={opts} ariaLabel={ariaLabel} />
152
+ return <Dropdown size="sm" variant="primary" value={value} onChange={onChange} options={opts} className={className} aria-label={ariaLabel} />
137
153
  }
138
154
 
139
155
  /* THE CONTROL CHIP (SettingsPanelCompliance, user 2026-08-27: "WROOOONG" on the
@@ -146,15 +162,26 @@ export const chipCls = (on) => `${CHIP_CLS} ${on ? 'kol-control--filled' : 'text
146
162
 
147
163
  /**
148
164
  * SettingsChipRow — a wrap of toggle chips with optional counts (an
149
- * allow-list: every chip sets a default, never a gate).
165
+ * allow-list: every chip sets a default, never a gate). `allChip` puts an
166
+ * "all" chip first — the same control chip, filled when every option is on
167
+ * (SettingsPanelEyebrowAndDropdowns — user: "Show all in ghost mode? makes no
168
+ * sense"); `onAll(nextAllOn)` fires when it is clicked.
150
169
  * @param {Array} options [{ value, label, count? }]
151
170
  * @param {Array|Set} selected the values that are on
152
171
  * @param {Function} onToggle (value) => void
172
+ * @param {boolean|string} allChip render the "all" chip (a string = its label)
173
+ * @param {Function} onAll (nextAllOn: boolean) => void
153
174
  */
154
- export function SettingsChipRow({ options = [], selected = [], onToggle }) {
175
+ export function SettingsChipRow({ options = [], selected = [], onToggle, allChip = false, onAll }) {
155
176
  const on = selected instanceof Set ? selected : new Set(selected)
177
+ const allOn = options.length > 0 && options.every((o) => on.has(o.value))
156
178
  return (
157
179
  <div className="flex flex-wrap gap-1">
180
+ {allChip && (
181
+ <button type="button" aria-pressed={allOn} onClick={() => onAll?.(!allOn)} className={chipCls(allOn)}>
182
+ {typeof allChip === 'string' ? allChip : 'all'}
183
+ </button>
184
+ )}
158
185
  {options.map((o) => (
159
186
  <button
160
187
  key={String(o.value)}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * markdownToHtml — a small, escape-first markdown → HTML renderer for
3
+ * `.kol-prose` (KindPreviewMarkdown, kol-r2b2 2026-08-27 — user: "why is it not
4
+ * rendering in preview using kol-prose?"). No dependency: the theme's prose CSS
5
+ * styles bare tags, so bare tags are what this emits. Covers headings,
6
+ * paragraphs, bold / italic / inline code, links and images, bullet and
7
+ * numbered lists, blockquotes, fenced code, rules and pipe tables. Every
8
+ * character is HTML-escaped before any markup is added; link and image URLs
9
+ * allow http(s), mailto, and relative paths only.
10
+ *
11
+ * ponytail: enough for READMEs and notes; a CommonMark engine is the upgrade if
12
+ * nested lists or reference links turn up.
13
+ */
14
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
15
+ const safeUrl = (u) => (/^(https?:|mailto:|\/|\.\/|\.\.\/|#|[a-z0-9_-]+(\/|\.|$))/i.test(u.trim()) && !/^javascript:/i.test(u.trim()) ? u.trim() : '#')
16
+
17
+ export const inlineToHtml = (raw) => {
18
+ let s = esc(raw)
19
+ s = s.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`)
20
+ s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => `<img alt="${alt}" src="${safeUrl(src)}">`)
21
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, href) => `<a href="${safeUrl(href)}">${t}</a>`)
22
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/__([^_]+)__/g, '<strong>$1</strong>')
23
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>').replace(/(^|[^_])_([^_\n]+)_/g, '$1<em>$2</em>')
24
+ return s
25
+ }
26
+
27
+ export default function markdownToHtml(markdown = '') {
28
+ const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
29
+ const out = []
30
+ let i = 0
31
+ const para = []
32
+ const flush = () => { if (para.length) { out.push(`<p>${inlineToHtml(para.join(' ').trim())}</p>`); para.length = 0 } }
33
+ /* frontmatter at the very top is skipped */
34
+ if (lines[0]?.trim() === '---') { const end = lines.indexOf('---', 1); if (end > 0) i = end + 1 }
35
+ while (i < lines.length) {
36
+ const line = lines[i]; const t = line.trim()
37
+ if (t.startsWith('```')) {
38
+ flush(); const lang = t.slice(3).trim(); const buf = []; i++
39
+ while (i < lines.length && !lines[i].trim().startsWith('```')) buf.push(lines[i++])
40
+ i++; out.push(`<pre><code${lang ? ` class="language-${esc(lang)}"` : ''}>${esc(buf.join('\n'))}</code></pre>`); continue
41
+ }
42
+ if (!t) { flush(); i++; continue }
43
+ const h = t.match(/^(#{1,6})\s+(.*)$/)
44
+ if (h) { flush(); out.push(`<h${h[1].length}>${inlineToHtml(h[2])}</h${h[1].length}>`); i++; continue }
45
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(t)) { flush(); out.push('<hr>'); i++; continue }
46
+ if (t.startsWith('>')) {
47
+ flush(); const buf = []
48
+ while (i < lines.length && lines[i].trim().startsWith('>')) buf.push(lines[i++].trim().replace(/^>\s?/, ''))
49
+ out.push(`<blockquote>${markdownToHtml(buf.join('\n'))}</blockquote>`); continue
50
+ }
51
+ if (/^[-*+]\s+/.test(t) || /^\d+[.)]\s+/.test(t)) {
52
+ flush(); const ordered = /^\d+[.)]\s+/.test(t); const items = []
53
+ while (i < lines.length && (/^[-*+]\s+/.test(lines[i].trim()) || /^\d+[.)]\s+/.test(lines[i].trim()))) items.push(lines[i++].trim().replace(/^([-*+]|\d+[.)])\s+/, ''))
54
+ out.push(`<${ordered ? 'ol' : 'ul'}>${items.map((it) => `<li>${inlineToHtml(it)}</li>`).join('')}</${ordered ? 'ol' : 'ul'}>`); continue
55
+ }
56
+ if (t.startsWith('|') && lines[i + 1]?.trim().match(/^\|?\s*:?-{2,}/)) {
57
+ flush(); const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => inlineToHtml(c.trim()))
58
+ const head = cells(t); i += 2; const rows = []
59
+ while (i < lines.length && lines[i].trim().startsWith('|')) rows.push(cells(lines[i++]))
60
+ out.push(`<table><thead><tr>${head.map((c) => `<th>${c}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`); continue
61
+ }
62
+ para.push(t); i++
63
+ }
64
+ flush()
65
+ return out.join('\n')
66
+ }
@@ -4,8 +4,10 @@
4
4
  * code (B2 hands back application/octet-stream), so the extension decides. */
5
5
 
6
6
  const EXT_KINDS = {
7
- json: 'text', yaml: 'text', yml: 'text', txt: 'text', md: 'text', csv: 'text',
8
- tsv: 'text', pgn: 'text', xml: 'text', svg: 'image',
7
+ /* markdown · json · yaml are their own kinds (user ruling 2026-08-27 "I want
8
+ * md always to show, make it unique"); text is the rest */
9
+ md: 'markdown', json: 'json', yaml: 'yaml', yml: 'yaml',
10
+ txt: 'text', csv: 'text', tsv: 'text', pgn: 'text', xml: 'text', svg: 'image',
9
11
  js: 'code', mjs: 'code', cjs: 'code', ts: 'code', jsx: 'code', tsx: 'code',
10
12
  css: 'code', html: 'code', sh: 'code', py: 'code',
11
13
  m3u8: 'playlist',
@@ -33,16 +35,17 @@ export function kindOf(o) {
33
35
  if (ct.startsWith('audio/')) return 'audio'
34
36
  const ext = extOf(o.key)
35
37
  if (EXT_KINDS[ext]) return EXT_KINDS[ext]
38
+ if (ct === 'text/markdown') return 'markdown'
39
+ if (ct === 'application/json') return 'json'
36
40
  if (ct.startsWith('text/')) return 'text'
37
41
  if (ct.startsWith('font/')) return 'font'
38
- if (ct === 'application/json') return 'text'
39
42
  return 'other'
40
43
  }
41
44
 
42
- export const KINDS = ['image', 'video', 'audio', 'text', 'code', 'playlist', 'font', 'archive', 'other']
45
+ export const KINDS = ['audio', 'video', 'image', 'markdown', 'json', 'yaml', 'text', 'code', 'playlist', 'font', 'archive', 'other']
43
46
 
44
47
  export const KIND_LABEL = {
45
- image: 'image', video: 'video', audio: 'audio', text: 'text', code: 'code',
48
+ image: 'image', video: 'video', audio: 'audio', markdown: 'markdown', json: 'JSON', yaml: 'YAML', text: 'text', code: 'code',
46
49
  playlist: 'HLS', font: 'font', archive: 'archive', segments: 'HLS segments',
47
50
  system: 'system', other: 'file',
48
51
  }