@dorsk/tsumikit 0.80.0 → 0.80.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.
@@ -4,7 +4,7 @@
4
4
  // initials (or an icon) when there is no source or it fails to load. Width
5
5
  // comes from `size`, height from `aspect`; `status` overlays a Dot/Badge in
6
6
  // the bottom-right corner.
7
- import type { Snippet } from 'svelte';
7
+ import { untrack, type Snippet } from 'svelte';
8
8
  import { artworkGradient, initials as initialsOf } from '../../artwork';
9
9
  import Icon from './Icon.svelte';
10
10
  import type { IconName } from './Icon.svelte';
@@ -51,23 +51,27 @@
51
51
  ...rest
52
52
  }: Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own = $props();
53
53
 
54
- let failed = $state(false);
55
- $effect(() => {
56
- src;
57
- srcset;
58
- failed = false;
59
- });
54
+ const source = $derived(`${src ?? ''} ${srcset ?? ''}`);
55
+ let failedSource = $state<string | null>(null);
60
56
 
61
57
  const key = $derived(seed ?? alt);
62
- const showImage = $derived(Boolean(src) && !failed);
58
+ const showImage = $derived(Boolean(src) && failedSource !== source);
63
59
  const tile = $derived(showImage ? undefined : alt.trim() ? 'named' : 'decorative');
64
60
  const gradient = $derived(showImage ? undefined : artworkGradient(key));
65
61
  const text = $derived(fallback === 'initials' ? initialsOf(key) : '');
66
62
 
67
63
  function fail() {
68
- failed = true;
64
+ failedSource = source;
69
65
  onerror?.();
70
66
  }
67
+
68
+ function watchLoad(img: HTMLImageElement) {
69
+ img.addEventListener('error', fail);
70
+ untrack(() => {
71
+ if (img.complete && img.naturalWidth === 0) fail();
72
+ });
73
+ return () => img.removeEventListener('error', fail);
74
+ }
71
75
  </script>
72
76
 
73
77
  <div
@@ -83,7 +87,7 @@
83
87
  {...rest}
84
88
  >
85
89
  {#if showImage}
86
- <img {src} {srcset} {sizes} {alt} class="img fit-{fit}" loading="lazy" decoding="async" onerror={fail} />
90
+ <img {src} {srcset} {sizes} {alt} class="img fit-{fit}" loading="lazy" decoding="async" {@attach watchLoad} />
87
91
  {:else if fallback === 'initials' && text}
88
92
  <span class="initials" aria-hidden="true">{text}</span>
89
93
  {:else if fallback === 'icon'}
@@ -1,5 +1,5 @@
1
1
  import type { HTMLAttributes } from 'svelte/elements';
2
- import type { Snippet } from 'svelte';
2
+ import { type Snippet } from 'svelte';
3
3
  import type { IconName } from './Icon.svelte';
4
4
  type Own = {
5
5
  src?: string | null;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { ComponentProps } from 'svelte';
3
3
  import SegmentedControl, { type SegmentOption } from './SegmentedControl.svelte';
4
+ import { browser } from '../../env';
4
5
  import { theme } from '../../stores/theme.svelte';
5
6
  import { LOOKS, type LookId } from '../../theme-mode';
6
7
 
@@ -40,7 +41,7 @@
40
41
  </script>
41
42
 
42
43
  {#snippet option(o: SegmentOption)}
43
- <span class="look">
44
+ <span class="look" data-look-option={o.value}>
44
45
  <span class="name">{o.label}</span>
45
46
  {#if experimentalLabel && tagged.has(o.value)}<span class="tag">{experimentalLabel}</span>{/if}
46
47
  </span>
@@ -48,7 +49,7 @@
48
49
 
49
50
  <SegmentedControl
50
51
  {options}
51
- value={theme.look}
52
+ value={browser ? theme.look : undefined}
52
53
  onchange={(v) => {
53
54
  theme.setLook(v);
54
55
  onchange?.(theme.look);
@@ -75,6 +76,18 @@
75
76
  overflow: hidden;
76
77
  text-overflow: ellipsis;
77
78
  }
79
+ /* Prerendered markup selects nothing; until hydration the look prepainted on <html> shows as selected. */
80
+ :global(:root:not([data-look]) .look-picker:not(:has(.selected)) .seg-item:has(> [data-look-option='default'])),
81
+ :global(:root[data-look='depth'] .look-picker:not(:has(.selected)) .seg-item:has(> [data-look-option='depth'])),
82
+ :global(:root[data-look='tactile'] .look-picker:not(:has(.selected)) .seg-item:has(> [data-look-option='tactile'])),
83
+ :global(:root[data-look='editorial'] .look-picker:not(:has(.selected)) .seg-item:has(> [data-look-option='editorial'])) {
84
+ color: var(--text);
85
+ background: var(--thumb-fill);
86
+ box-shadow: var(--thumb-shadow);
87
+ }
88
+ :global(:root[data-look='editorial'] .look-picker:not(:has(.selected)) .seg-item:has(> [data-look-option='editorial'])) {
89
+ color: var(--bg);
90
+ }
78
91
  .tag {
79
92
  flex: none;
80
93
  padding: 0 var(--sp-1);
@@ -258,6 +258,8 @@
258
258
  border: 0;
259
259
  background: none;
260
260
  font: inherit;
261
+ text-transform: inherit;
262
+ letter-spacing: inherit;
261
263
  color: inherit;
262
264
  text-align: start;
263
265
  cursor: pointer;
@@ -5,9 +5,12 @@
5
5
  // (--c-bg, --c-surface, --c-text, --c-accent; the derived --bg etc. are
6
6
  // resolved once at :root and would not re-scope). The root default theme
7
7
  // has no [data-theme] block, so its swatch carries the :root values.
8
+ // Prerendered, the trigger swatch is unscoped: it shows the theme prepainted
9
+ // on <html> until hydration.
8
10
  import type { ComponentProps } from 'svelte';
9
11
  import type { AnchorAttributes } from '../../anchor';
10
12
  import Popover from './Popover.svelte';
13
+ import { browser } from '../../env';
11
14
  import { type ThemeDef, theme } from '../../stores/theme.svelte';
12
15
  import { AUTO_THEME } from '../../theme-mode';
13
16
 
@@ -63,7 +66,9 @@
63
66
  );
64
67
  const named = (id: string) => theme.all.find((t) => t.id === id)?.label ?? id;
65
68
  const autoCaption = $derived(`${autoLabel} · ${named(theme.pref.light)} / ${named(theme.pref.dark)}`);
66
- const title = $derived(isAuto ? `${autoLabel} · ${theme.label}` : `Theme: ${theme.label}`);
69
+ const title = $derived(
70
+ !browser ? 'Theme' : isAuto ? `${autoLabel} · ${theme.label}` : `Theme: ${theme.label}`
71
+ );
67
72
  const caption = $derived(
68
73
  hoveredAuto || (isAuto && !hovered)
69
74
  ? autoCaption
@@ -71,7 +76,7 @@
71
76
  );
72
77
  </script>
73
78
 
74
- {#snippet swatch(id: string)}
79
+ {#snippet swatch(id: string | undefined)}
75
80
  <span class="swatch" data-theme={id} aria-hidden="true">
76
81
  <i class="q bg"></i><i class="q surface"></i><i class="q text"></i><i class="q accent"></i>
77
82
  </span>
@@ -94,7 +99,7 @@
94
99
  triggerClass={klass}
95
100
  style={styleProp}
96
101
  >
97
- {#snippet trigger()}<span class="trigger" data-tsu="ThemePicker" {title}>{@render swatch(theme.current)}{#if isAuto}<span class="auto-dot" aria-hidden="true">◐</span>{/if}</span>{/snippet}
102
+ {#snippet trigger()}<span class="trigger" data-tsu="ThemePicker" {title}>{@render swatch(browser ? theme.current : undefined)}{#if isAuto}<span class="auto-dot" aria-hidden="true">◐</span>{/if}</span>{/snippet}
98
103
  <div class="panel">
99
104
  {#each groups as g (g.mode)}
100
105
  <div class="group-label">{g.label}</div>
@@ -316,12 +316,12 @@
316
316
  transition-duration: var(--dur-press);
317
317
  transition-timing-function: var(--ease-out);
318
318
  }
319
- :root[data-look="tactile"] .row {
319
+ :root[data-look="tactile"] [data-tsu="RadioGroup"] .row {
320
320
  border-color: transparent;
321
321
  border-radius: var(--r-lg);
322
322
  background: var(--bg-elevated-2);
323
323
  }
324
- :root[data-look="tactile"] .row.selected {
324
+ :root[data-look="tactile"] [data-tsu="RadioGroup"] .row.selected {
325
325
  border-color: transparent;
326
326
  background: color-mix(in oklab, var(--accent) 12%, var(--bg-elevated));
327
327
  }
package/dist/markdown.js CHANGED
@@ -89,7 +89,9 @@ function inline(s, autolink) {
89
89
  function splitRow(row) {
90
90
  const cells = [];
91
91
  let cur = '';
92
- const t = row.trim().replace(/^\|/, '').replace(/\|$/, '');
92
+ let t = row.trim();
93
+ if (t.startsWith('|'))
94
+ t = t.slice(1);
93
95
  for (let i = 0; i < t.length; i++) {
94
96
  if (t[i] === '\\' && t[i + 1] === '|') {
95
97
  cur += '|';
@@ -102,32 +104,57 @@ function splitRow(row) {
102
104
  else
103
105
  cur += t[i];
104
106
  }
105
- cells.push(cur);
107
+ if (cur.trim() || !cells.length)
108
+ cells.push(cur);
106
109
  return cells;
107
110
  }
108
- const TABLE_RE = /(?:^|\n)([ \t]*\|.+\|[ \t]*)\n([ \t]*\|(?:[ \t]*:?-+:?[ \t]*\|)+[ \t]*)\n((?:[ \t]*\|.*\|[ \t]*(?:\n|$))+)/g;
111
+ const hasPipe = (line) => /(?:^|[^\\])\|/.test(line);
112
+ const DELIM_CELL = /^\s*:?-+:?\s*$/;
113
+ const TABLE_BREAK = new RegExp(`^\\s*(?:$|#{1,6}(?:\\s|$)|&gt;|(?:[-*+]|\\d{1,9}[.)])\\s|(?:-{3,}|\\*{3,}|_{3,})\\s*$|${BLOCK_L}\\d+${BLOCK_R}\\s*$)`);
109
114
  function table(header, delim, body, autolink) {
110
- const aligns = splitRow(delim).map((c) => {
115
+ const aligns = delim.map((c) => {
111
116
  const l = c.trim().startsWith(':');
112
117
  const r = c.trim().endsWith(':');
113
118
  return r && l ? 'center' : r ? 'right' : l ? 'left' : '';
114
119
  });
115
- const cell = (txt, i, tag) => {
116
- const a = aligns[i] ? ` style="text-align:${aligns[i]}"` : '';
117
- return `<${tag}${a}>${inline(txt.trim(), autolink)}</${tag}>`;
120
+ const row = (line, tag) => {
121
+ const cells = splitRow(line);
122
+ const html = aligns.map((a, i) => {
123
+ const style = a ? ` style="text-align:${a}"` : '';
124
+ return `<${tag}${style}>${inline((cells[i] ?? '').trim(), autolink)}</${tag}>`;
125
+ });
126
+ return `<tr>${html.join('')}</tr>`;
118
127
  };
119
- const head = `<tr>${splitRow(header)
120
- .map((c, i) => cell(c, i, 'th'))
121
- .join('')}</tr>`;
122
- const rows = body
123
- .split('\n')
124
- .filter((r) => r.trim())
125
- .map((r) => `<tr>${splitRow(r)
126
- .map((c, i) => cell(c, i, 'td'))
127
- .join('')}</tr>`)
128
- .join('');
128
+ const head = row(header, 'th');
129
+ const rows = body.map((r) => row(r, 'td')).join('');
129
130
  return `<table><thead>${head}</thead><tbody>${rows}</tbody></table>`;
130
131
  }
132
+ // GFM pipe tables on escaped text: a header row, a delimiter row with the
133
+ // same cell count, then rows up to a blank line or the start of another block.
134
+ function tables(s, saved, autolink) {
135
+ const lines = s.split('\n');
136
+ const out = [];
137
+ for (let i = 0; i < lines.length; i++) {
138
+ const head = lines[i];
139
+ const next = lines[i + 1];
140
+ if (next === undefined || !hasPipe(head) || !hasPipe(next) || TABLE_BREAK.test(head)) {
141
+ out.push(head);
142
+ continue;
143
+ }
144
+ const delim = splitRow(next);
145
+ const cols = splitRow(head);
146
+ if (!delim.every((c) => DELIM_CELL.test(c)) || cols.length !== delim.length) {
147
+ out.push(head);
148
+ continue;
149
+ }
150
+ let j = i + 2;
151
+ while (j < lines.length && !TABLE_BREAK.test(lines[j]))
152
+ j++;
153
+ out.push('', stash(saved, table(head, delim, lines.slice(i + 2, j), autolink)), '');
154
+ i = j - 1;
155
+ }
156
+ return out.join('\n');
157
+ }
131
158
  // Block pass over the escaped, inline-rendered lines. Produces paragraphs,
132
159
  // headings, lists, blockquotes and rules; stashed blocks stay opaque.
133
160
  function blocks(lines) {
@@ -209,7 +236,7 @@ function blocks(lines) {
209
236
  return out.join('');
210
237
  }
211
238
  export function renderMarkdown(src, opts = {}) {
212
- const tables = opts.tables !== false;
239
+ const withTables = opts.tables !== false;
213
240
  const autolink = opts.autolink !== false;
214
241
  const saved = [];
215
242
  let s = stripAnsi(src.replace(/\r\n?/g, '\n'));
@@ -221,9 +248,8 @@ export function renderMarkdown(src, opts = {}) {
221
248
  return stash(saved, `<pre${attr}><code>${body}</code></pre>`);
222
249
  });
223
250
  s = escapeHtml(s);
224
- if (tables) {
225
- s = s.replace(TABLE_RE, (_m, header, delim, body) => `\n${stash(saved, table(header, delim, body, autolink))}\n`);
226
- }
251
+ if (withTables)
252
+ s = tables(s, saved, autolink);
227
253
  s = inline(s, autolink);
228
254
  s = blocks(s.split('\n'));
229
255
  return restore(s, saved);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.80.0",
3
+ "version": "0.80.1",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",