@dorsk/tsumikit 0.27.0 → 0.29.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.
@@ -5,7 +5,12 @@
5
5
  // rows); `as` lets it be a button/anchor when the whole surface is clickable.
6
6
  // `padding` dials the inner spacing (none/sm/md/lg) for denser cards.
7
7
  // `tone` tints the surface itself (border + faint background wash) with a
8
- // semantic hue for inline banners; `neutral` is the plain card.
8
+ // semantic hue for inline banners; `neutral` is the plain card; `attention`
9
+ // adds a left bar for needs-input rows.
10
+ //
11
+ // `interactive` makes a non-button/anchor card keyboard-operable
12
+ // (role=button, tabindex, Enter/Space) and ignores clicks originating from
13
+ // nested interactive elements so inner buttons/links keep working.
9
14
  //
10
15
  // `stacked` fakes a pile of cards by drawing two layers peeking out below
11
16
  // (and optionally to the right) via pseudo-elements. `stackTone` tints those
@@ -20,10 +25,11 @@
20
25
  import type { Snippet } from 'svelte';
21
26
  import SectionHeader from '../molecules/SectionHeader.svelte';
22
27
 
23
- type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
28
+ type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info' | 'attention';
24
29
 
25
30
  let {
26
31
  tap = false,
32
+ interactive = false,
27
33
  as = 'div',
28
34
  padding = 'md',
29
35
  surface = 'base',
@@ -35,6 +41,8 @@
35
41
  title,
36
42
  subtitle,
37
43
  gap,
44
+ maxWidth,
45
+ onclick,
38
46
  class: klass = '',
39
47
  style = '',
40
48
  header,
@@ -44,6 +52,7 @@
44
52
  ...rest
45
53
  }: {
46
54
  tap?: boolean;
55
+ interactive?: boolean;
47
56
  as?: 'div' | 'button' | 'a' | 'li' | 'section' | 'form';
48
57
  padding?: 'none' | 'sm' | 'md' | 'lg';
49
58
  surface?: 'base' | 'raised' | 'sunken';
@@ -55,6 +64,8 @@
55
64
  title?: string;
56
65
  subtitle?: string;
57
66
  gap?: string;
67
+ maxWidth?: string;
68
+ onclick?: (e: MouseEvent | KeyboardEvent) => void;
58
69
  class?: string;
59
70
  style?: string;
60
71
  header?: Snippet;
@@ -68,6 +79,26 @@
68
79
  stacked ? `--stack-y:${stackY}px;--stack-x:${stackX}px;` : ''
69
80
  );
70
81
  const framed = $derived(!!(header || footer || title));
82
+ const native = $derived(as === 'button' || as === 'a');
83
+
84
+ const NESTED = 'button,a,input,select,textarea,[role=button],[contenteditable],[contenteditable=true]';
85
+ function fromNested(e: Event): boolean {
86
+ const target = e.target as Element | null;
87
+ const hit = target?.closest?.(NESTED);
88
+ return !!hit && hit !== e.currentTarget;
89
+ }
90
+ function handleClick(e: MouseEvent) {
91
+ if (!onclick) return;
92
+ if (interactive && fromNested(e)) return;
93
+ onclick(e);
94
+ }
95
+ function handleKeydown(e: KeyboardEvent) {
96
+ if (!interactive || native || !onclick) return;
97
+ if (e.key !== 'Enter' && e.key !== ' ') return;
98
+ if (fromNested(e)) return;
99
+ if (e.key === ' ') e.preventDefault();
100
+ onclick(e);
101
+ }
71
102
  </script>
72
103
 
73
104
  <svelte:element
@@ -79,11 +110,13 @@
79
110
  class:pad-lg={padding === 'lg'}
80
111
  class:surface-raised={surface === 'raised'}
81
112
  class:surface-sunken={surface === 'sunken'}
82
- class:card-tap={tap}
113
+ class:card-tap={tap || interactive}
114
+ class:card-max={maxWidth !== undefined}
83
115
  class:card-ok={tone === 'ok'}
84
116
  class:card-warn={tone === 'warn'}
85
117
  class:card-danger={tone === 'danger'}
86
118
  class:card-info={tone === 'info'}
119
+ class:card-attention={tone === 'attention'}
87
120
  class:card-stacked={stacked}
88
121
  class:stack-ok={stacked && stackTone === 'ok'}
89
122
  class:stack-warn={stacked && stackTone === 'warn'}
@@ -92,7 +125,12 @@
92
125
  class:card-framed={framed}
93
126
  class:card-gap={!framed && gap !== undefined}
94
127
  style:--card-gap={gap}
128
+ style:max-width={maxWidth}
95
129
  style={`${stackStyle}${style}`}
130
+ role={interactive && !native ? 'button' : undefined}
131
+ tabindex={interactive && !native ? 0 : undefined}
132
+ onclick={handleClick}
133
+ onkeydown={handleKeydown}
96
134
  {...rest}
97
135
  >
98
136
  {#if framed}
@@ -177,6 +215,9 @@
177
215
  .card-tap:hover {
178
216
  border-color: var(--border-strong);
179
217
  }
218
+ .card-max {
219
+ width: 100%;
220
+ }
180
221
 
181
222
  .card-ok {
182
223
  --card-tone: var(--ok);
@@ -190,13 +231,21 @@
190
231
  .card-info {
191
232
  --card-tone: var(--info);
192
233
  }
234
+ .card-attention {
235
+ --card-tone: var(--attention-bar);
236
+ }
193
237
  .card-ok,
194
238
  .card-warn,
195
239
  .card-danger,
196
- .card-info {
240
+ .card-info,
241
+ .card-attention {
197
242
  border-color: color-mix(in srgb, var(--card-tone) 55%, var(--border));
198
243
  background: color-mix(in srgb, var(--card-tone) 8%, var(--bg-elevated));
199
244
  }
245
+ .card-attention {
246
+ background: var(--attention-bg);
247
+ box-shadow: inset 3px 0 0 var(--attention-bar);
248
+ }
200
249
 
201
250
  /* Stacked effect — two back layers peeking out bottom-right. The front
202
251
  surface keeps its own background so the layers only show at the edges. */
@@ -1,7 +1,8 @@
1
1
  import type { Snippet } from 'svelte';
2
- type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
2
+ type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info' | 'attention';
3
3
  type $$ComponentProps = {
4
4
  tap?: boolean;
5
+ interactive?: boolean;
5
6
  as?: 'div' | 'button' | 'a' | 'li' | 'section' | 'form';
6
7
  padding?: 'none' | 'sm' | 'md' | 'lg';
7
8
  surface?: 'base' | 'raised' | 'sunken';
@@ -13,6 +14,8 @@ type $$ComponentProps = {
13
14
  title?: string;
14
15
  subtitle?: string;
15
16
  gap?: string;
17
+ maxWidth?: string;
18
+ onclick?: (e: MouseEvent | KeyboardEvent) => void;
16
19
  class?: string;
17
20
  style?: string;
18
21
  header?: Snippet;
@@ -11,6 +11,15 @@
11
11
  level = 2,
12
12
  size,
13
13
  tone = 'default',
14
+ truncate = false,
15
+ italic = false,
16
+ nowrap = false,
17
+ wrap = 'normal',
18
+ uppercase = false,
19
+ leading,
20
+ measure,
21
+ grow = false,
22
+ scale = true,
14
23
  class: klass = '',
15
24
  children,
16
25
  ...rest
@@ -18,6 +27,17 @@
18
27
  level?: 1 | 2 | 3 | 4 | 5 | 6;
19
28
  size?: Size;
20
29
  tone?: 'default' | 'muted' | 'faint';
30
+ truncate?: boolean;
31
+ italic?: boolean;
32
+ nowrap?: boolean;
33
+ wrap?: 'normal' | 'anywhere' | 'balance';
34
+ uppercase?: boolean;
35
+ leading?: 'tight' | 'normal' | 'none';
36
+ // CSS max-width for the line measure, e.g. "40ch".
37
+ measure?: string;
38
+ grow?: boolean;
39
+ // false pins the size to its unscaled px value, ignoring --fs-scale.
40
+ scale?: boolean;
21
41
  class?: string;
22
42
  children?: Snippet;
23
43
  [key: string]: unknown;
@@ -26,9 +46,22 @@
26
46
  // Default display size per rank (h1 biggest). `size` overrides it.
27
47
  const DEFAULT_SIZE: Record<number, Size> = { 1: '2xl', 2: 'xl', 3: 'lg', 4: 'md', 5: 'sm', 6: 'xs' };
28
48
  const fs = $derived(size ?? DEFAULT_SIZE[level]);
49
+ const styleAttr = $derived(measure ? `max-width: ${measure}` : undefined);
29
50
  </script>
30
51
 
31
- <svelte:element this={`h${level}`} data-tsu="Heading" class="heading fs-{fs} tone-{tone} {klass}" {...rest}>
52
+ <svelte:element
53
+ this={`h${level}`}
54
+ data-tsu="Heading"
55
+ class="heading fs-{fs} tone-{tone} {leading ? `lh-${leading}` : ''} {wrap !== 'normal' ? `wrap-${wrap}` : ''} {klass}"
56
+ class:truncate
57
+ class:italic
58
+ class:nowrap
59
+ class:uppercase
60
+ class:grow
61
+ class:noscale={!scale}
62
+ style={styleAttr}
63
+ {...rest}
64
+ >
32
65
  {@render children?.()}
33
66
  </svelte:element>
34
67
 
@@ -63,4 +96,59 @@
63
96
  .fs-2xl {
64
97
  font-size: var(--fs-2xl);
65
98
  }
99
+ .noscale.fs-xs {
100
+ font-size: 12px;
101
+ }
102
+ .noscale.fs-sm {
103
+ font-size: 13px;
104
+ }
105
+ .noscale.fs-md {
106
+ font-size: 16px;
107
+ }
108
+ .noscale.fs-lg {
109
+ font-size: 18px;
110
+ }
111
+ .noscale.fs-xl {
112
+ font-size: 22px;
113
+ }
114
+ .noscale.fs-2xl {
115
+ font-size: 28px;
116
+ }
117
+ .truncate {
118
+ max-width: 100%;
119
+ overflow: hidden;
120
+ text-overflow: ellipsis;
121
+ white-space: nowrap;
122
+ min-width: 0;
123
+ }
124
+ .italic {
125
+ font-style: italic;
126
+ }
127
+ .nowrap {
128
+ white-space: nowrap;
129
+ }
130
+ .wrap-anywhere {
131
+ min-width: 0;
132
+ overflow-wrap: anywhere;
133
+ }
134
+ .wrap-balance {
135
+ text-wrap: balance;
136
+ }
137
+ .uppercase {
138
+ text-transform: uppercase;
139
+ letter-spacing: 0.04em;
140
+ }
141
+ .lh-tight {
142
+ line-height: var(--lh-tight);
143
+ }
144
+ .lh-normal {
145
+ line-height: var(--lh-normal);
146
+ }
147
+ .lh-none {
148
+ line-height: 1;
149
+ }
150
+ .grow {
151
+ flex: 1 1 0;
152
+ min-width: 0;
153
+ }
66
154
  </style>
@@ -4,6 +4,15 @@ type $$ComponentProps = {
4
4
  level?: 1 | 2 | 3 | 4 | 5 | 6;
5
5
  size?: Size;
6
6
  tone?: 'default' | 'muted' | 'faint';
7
+ truncate?: boolean;
8
+ italic?: boolean;
9
+ nowrap?: boolean;
10
+ wrap?: 'normal' | 'anywhere' | 'balance';
11
+ uppercase?: boolean;
12
+ leading?: 'tight' | 'normal' | 'none';
13
+ measure?: string;
14
+ grow?: boolean;
15
+ scale?: boolean;
7
16
  class?: string;
8
17
  children?: Snippet;
9
18
  [key: string]: unknown;
@@ -24,7 +24,8 @@
24
24
  label?: string;
25
25
  // Fill colour. `accent` is the default brand fill; the semantic tones
26
26
  // retint the bar for severity (e.g. usage meters going warm/hot).
27
- tone?: 'accent' | 'success' | 'warn' | 'danger';
27
+ // `ok` and `success` are aliases.
28
+ tone?: 'accent' | 'ok' | 'success' | 'warn' | 'danger';
28
29
  // Track height. `sm` is a thin ~5px track for inline rows.
29
30
  size?: 'sm' | 'md';
30
31
  // Accent→teal gradient fill (overrides the flat tone colour).
@@ -38,11 +39,12 @@
38
39
 
39
40
  const pct = $derived(value == null ? 0 : Math.max(0, Math.min(100, (value / max) * 100)));
40
41
  const indeterminate = $derived(indeterminateProp || value == null);
42
+ const toneClass = $derived(tone === 'ok' ? 'success' : tone);
41
43
  </script>
42
44
 
43
45
  <div
44
46
  data-tsu="Progress"
45
- class="progress tone-{tone} size-{size} {klass}"
47
+ class="progress tone-{toneClass} size-{size} {klass}"
46
48
  class:indeterminate
47
49
  class:gradient
48
50
  class:striped
@@ -2,7 +2,7 @@ type $$ComponentProps = {
2
2
  value?: number;
3
3
  max?: number;
4
4
  label?: string;
5
- tone?: 'accent' | 'success' | 'warn' | 'danger';
5
+ tone?: 'accent' | 'ok' | 'success' | 'warn' | 'danger';
6
6
  size?: 'sm' | 'md';
7
7
  gradient?: boolean;
8
8
  striped?: boolean;
@@ -6,6 +6,7 @@
6
6
  // inline glue text without changing rendering. All values come from tokens.
7
7
  import type { Snippet } from 'svelte';
8
8
 
9
+
9
10
  type Size = 'xs' | 'sm' | 'base' | 'md' | 'lg' | 'xl' | '2xl';
10
11
 
11
12
  let {
@@ -16,33 +17,68 @@
16
17
  size,
17
18
  numeric = false,
18
19
  truncate = false,
20
+ italic = false,
21
+ nowrap = false,
22
+ wrap = 'normal',
23
+ uppercase = false,
24
+ leading,
25
+ measure,
26
+ grow = false,
27
+ scale = true,
28
+ block = false,
19
29
  class: klass = '',
20
30
  children,
21
31
  ...rest
22
32
  }: {
23
33
  as?: 'span' | 'p' | 'div' | 'label';
24
34
  // body: default reading text · label: form-label · caption: small meta ·
25
- // code: monospace. Omit for inline glue that should inherit its parent.
26
- variant?: 'body' | 'label' | 'caption' | 'code';
27
- tone?: 'inherit' | 'default' | 'muted' | 'faint' | 'success' | 'warn' | 'danger' | 'accent';
35
+ // code: monospace · eyebrow: small uppercase muted kicker. Omit for inline
36
+ // glue that should inherit its parent.
37
+ variant?: 'body' | 'label' | 'caption' | 'code' | 'eyebrow';
38
+ // `ok` and `success` are aliases.
39
+ tone?: 'inherit' | 'default' | 'muted' | 'faint' | 'ok' | 'success' | 'warn' | 'danger' | 'accent';
28
40
  weight?: 'normal' | 'medium' | 'semibold' | 'bold';
29
41
  size?: Size;
30
42
  // Tabular figures: digits share a fixed advance width so counts/percentages
31
43
  // don't jitter as they change. Use for counters, timers, metrics.
32
44
  numeric?: boolean;
33
45
  truncate?: boolean;
46
+ italic?: boolean;
47
+ nowrap?: boolean;
48
+ wrap?: 'normal' | 'anywhere' | 'balance';
49
+ uppercase?: boolean;
50
+ leading?: 'tight' | 'normal' | 'none';
51
+ // CSS max-width for the line measure, e.g. "60ch".
52
+ measure?: string;
53
+ grow?: boolean;
54
+ // false pins the size to its unscaled px value, ignoring --fs-scale.
55
+ scale?: boolean;
56
+ block?: boolean;
34
57
  class?: string;
35
58
  children?: Snippet;
36
59
  [key: string]: unknown;
37
60
  } = $props();
61
+
62
+ const toneClass = $derived(tone === 'ok' ? 'success' : tone);
63
+ const styleAttr = $derived(measure ? `max-width: ${measure}` : undefined);
38
64
  </script>
39
65
 
40
66
  <svelte:element
41
67
  this={as}
42
68
  data-tsu="Text"
43
- class="text {variant ? `v-${variant}` : ''} tone-{tone} {weight ? `fw-${weight}` : ''} {size
69
+ class="text {variant ? `v-${variant}` : ''} tone-{toneClass} {weight ? `fw-${weight}` : ''} {size
44
70
  ? `fs-${size}`
45
- : ''} {numeric ? 'numeric' : ''} {truncate ? 'truncate' : ''} {klass}"
71
+ : ''} {numeric ? 'numeric' : ''} {truncate ? 'truncate' : ''} {leading ? `lh-${leading}` : ''} {wrap !==
72
+ 'normal'
73
+ ? `wrap-${wrap}`
74
+ : ''} {klass}"
75
+ class:italic
76
+ class:nowrap
77
+ class:uppercase
78
+ class:grow
79
+ class:block
80
+ class:noscale={!scale}
81
+ style={styleAttr}
46
82
  {...rest}
47
83
  >
48
84
  {@render children?.()}
@@ -72,6 +108,13 @@
72
108
  font-family: var(--font-mono);
73
109
  font-size: 0.92em;
74
110
  }
111
+ .v-eyebrow {
112
+ font-size: var(--fs-xs);
113
+ color: var(--text-muted);
114
+ font-weight: var(--fw-medium);
115
+ text-transform: uppercase;
116
+ letter-spacing: 0.04em;
117
+ }
75
118
  /* Tone (colour) — overrides variant colour. */
76
119
  .tone-default {
77
120
  color: var(--text);
@@ -129,6 +172,60 @@
129
172
  .fs-2xl {
130
173
  font-size: var(--fs-2xl);
131
174
  }
175
+ .noscale.fs-xs {
176
+ font-size: 12px;
177
+ }
178
+ .noscale.fs-sm {
179
+ font-size: 13px;
180
+ }
181
+ .noscale.fs-base {
182
+ font-size: 15px;
183
+ }
184
+ .noscale.fs-md {
185
+ font-size: 16px;
186
+ }
187
+ .noscale.fs-lg {
188
+ font-size: 18px;
189
+ }
190
+ .noscale.fs-xl {
191
+ font-size: 22px;
192
+ }
193
+ .noscale.fs-2xl {
194
+ font-size: 28px;
195
+ }
196
+ .italic {
197
+ font-style: italic;
198
+ }
199
+ .nowrap {
200
+ white-space: nowrap;
201
+ }
202
+ .wrap-anywhere {
203
+ min-width: 0;
204
+ overflow-wrap: anywhere;
205
+ }
206
+ .wrap-balance {
207
+ text-wrap: balance;
208
+ }
209
+ .uppercase {
210
+ text-transform: uppercase;
211
+ letter-spacing: 0.04em;
212
+ }
213
+ .lh-tight {
214
+ line-height: var(--lh-tight);
215
+ }
216
+ .lh-normal {
217
+ line-height: var(--lh-normal);
218
+ }
219
+ .lh-none {
220
+ line-height: 1;
221
+ }
222
+ .grow {
223
+ flex: 1 1 0;
224
+ min-width: 0;
225
+ }
226
+ .block {
227
+ display: block;
228
+ }
132
229
  .numeric {
133
230
  font-variant-numeric: tabular-nums;
134
231
  font-feature-settings: 'tnum';
@@ -2,12 +2,21 @@ import type { Snippet } from 'svelte';
2
2
  type Size = 'xs' | 'sm' | 'base' | 'md' | 'lg' | 'xl' | '2xl';
3
3
  type $$ComponentProps = {
4
4
  as?: 'span' | 'p' | 'div' | 'label';
5
- variant?: 'body' | 'label' | 'caption' | 'code';
6
- tone?: 'inherit' | 'default' | 'muted' | 'faint' | 'success' | 'warn' | 'danger' | 'accent';
5
+ variant?: 'body' | 'label' | 'caption' | 'code' | 'eyebrow';
6
+ tone?: 'inherit' | 'default' | 'muted' | 'faint' | 'ok' | 'success' | 'warn' | 'danger' | 'accent';
7
7
  weight?: 'normal' | 'medium' | 'semibold' | 'bold';
8
8
  size?: Size;
9
9
  numeric?: boolean;
10
10
  truncate?: boolean;
11
+ italic?: boolean;
12
+ nowrap?: boolean;
13
+ wrap?: 'normal' | 'anywhere' | 'balance';
14
+ uppercase?: boolean;
15
+ leading?: 'tight' | 'normal' | 'none';
16
+ measure?: string;
17
+ grow?: boolean;
18
+ scale?: boolean;
19
+ block?: boolean;
11
20
  class?: string;
12
21
  children?: Snippet;
13
22
  [key: string]: unknown;
package/dist/index.d.ts CHANGED
@@ -69,4 +69,5 @@ export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.sve
69
69
  export { type Mode, THEMES, theme } from './stores/theme.svelte';
70
70
  export { type Toast, type ToastAction, type ToastOptions, type ToastTone, type ToastToneInput, toasts, } from './stores/toast.svelte';
71
71
  export { formatTimestamp, localTimeZone, relativeTime, type TimeInput, type TimestampMode, } from './timestamp';
72
+ export { canonicalTone, type Tone } from './tone';
72
73
  export { type TruncateMode, type TruncateOptions, truncate } from './truncate';
package/dist/index.js CHANGED
@@ -82,4 +82,5 @@ export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
82
82
  export { THEMES, theme } from './stores/theme.svelte';
83
83
  export { toasts, } from './stores/toast.svelte';
84
84
  export { formatTimestamp, localTimeZone, relativeTime, } from './timestamp';
85
+ export { canonicalTone } from './tone';
85
86
  export { truncate } from './truncate';
package/dist/tone.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type Tone = 'neutral' | 'ok' | 'success' | 'warn' | 'danger' | 'info' | 'accent';
2
+ export declare function canonicalTone(t: string): string;
package/dist/tone.js ADDED
@@ -0,0 +1,3 @@
1
+ export function canonicalTone(t) {
2
+ return t === 'success' ? 'ok' : t;
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
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",