svelte-theme-picker 1.0.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/README.md ADDED
@@ -0,0 +1,686 @@
1
+ # svelte-theme-picker
2
+
3
+ A beautiful, customizable theme picker component for Svelte 5 applications.
4
+
5
+ ## Features
6
+
7
+ - 10 beautiful built-in themes (dark and light modes)
8
+ - Fully customizable - add your own themes
9
+ - Persists selection to localStorage
10
+ - Applies CSS variables to your document
11
+ - Svelte 5 runes compatible
12
+ - Full TypeScript support
13
+ - Floating button or inline mode (vertical or horizontal)
14
+ - **Preview mode** - temporarily apply themes without persisting
15
+ - **JSON-driven themes** - define themes in JSON with production/test filtering
16
+ - **Theme catalogs** - organize themes with metadata, tags, and filtering
17
+ - **Headless mode** - use the store without any UI for full programmatic control
18
+ - **Performance optimized** - GPU-accelerated animations, batched DOM updates
19
+ - **Accessible** - ARIA labels, keyboard navigation (Escape to close)
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install svelte-theme-picker
25
+ # or
26
+ pnpm add svelte-theme-picker
27
+ # or
28
+ yarn add svelte-theme-picker
29
+ ```
30
+
31
+ ## Basic Usage
32
+
33
+ ```svelte
34
+ <script>
35
+ import { ThemePicker } from 'svelte-theme-picker';
36
+ </script>
37
+
38
+ <ThemePicker />
39
+ ```
40
+
41
+ This adds a floating theme picker button to the bottom-right of your page.
42
+
43
+ ## Configuration
44
+
45
+ ### Props
46
+
47
+ | Prop | Type | Default | Description |
48
+ |------|------|---------|-------------|
49
+ | `config` | `ThemePickerConfig` | `{}` | Configuration options |
50
+ | `store` | `ThemeStore` | `undefined` | Custom theme store |
51
+ | `position` | `'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left'` | `'bottom-right'` | Position of trigger button |
52
+ | `showTrigger` | `boolean` | `true` | Show floating button (false = inline mode) |
53
+ | `layout` | `'vertical' \| 'horizontal'` | `'vertical'` | Layout direction for inline mode |
54
+ | `onThemeChange` | `(id: string, theme: Theme) => void` | `undefined` | Callback when theme changes |
55
+
56
+ ### Inline Layouts
57
+
58
+ ```svelte
59
+ <!-- Vertical list with names and descriptions -->
60
+ <ThemePicker showTrigger={false} layout="vertical" />
61
+
62
+ <!-- Horizontal grid of color swatches (compact) -->
63
+ <ThemePicker showTrigger={false} layout="horizontal" />
64
+ ```
65
+
66
+ ### Configuration Options
67
+
68
+ ```typescript
69
+ interface ThemePickerConfig {
70
+ storageKey?: string; // localStorage key (default: 'svelte-theme-picker')
71
+ defaultTheme?: string; // Default theme ID (default: 'dreamy')
72
+ themes?: Record<string, Theme>; // Custom themes
73
+ cssVarPrefix?: string; // CSS variable prefix
74
+ }
75
+ ```
76
+
77
+ ## Headless Mode (No UI)
78
+
79
+ The `ThemePicker` component is completely optional. You can use just the store and utilities for full programmatic control without rendering any UI. This is useful when:
80
+
81
+ - You want to control themes from your own settings page
82
+ - Themes are set based on user preferences from a database
83
+ - You're building a custom theme switcher UI
84
+ - You want to disable user theme switching entirely
85
+
86
+ ```svelte
87
+ <script>
88
+ // No ThemePicker component needed!
89
+ import { themeStore, applyTheme, defaultThemes } from 'svelte-theme-picker';
90
+ import { onMount } from 'svelte';
91
+
92
+ onMount(() => {
93
+ // Load theme from user preferences, database, API, etc.
94
+ const userTheme = getUserPreference() || 'dreamy';
95
+ themeStore.setTheme(userTheme);
96
+ applyTheme(defaultThemes[userTheme]);
97
+
98
+ // Optionally subscribe to persist changes
99
+ return themeStore.subscribe((themeId) => {
100
+ saveUserPreference(themeId);
101
+ });
102
+ });
103
+ </script>
104
+
105
+ <!-- Your app content - no picker UI rendered -->
106
+ <slot />
107
+ ```
108
+
109
+ You get all the benefits (localStorage persistence, CSS variable application, TypeScript types) without any visible theme picker.
110
+
111
+ ## Preview Mode (Temporary Themes)
112
+
113
+ Perfect for form-based theme selection where you want to preview themes without persisting them:
114
+
115
+ ```svelte
116
+ <script>
117
+ import { themeStore, applyTheme, defaultThemes } from 'svelte-theme-picker';
118
+ import { onMount } from 'svelte';
119
+
120
+ let selectedTheme = 'dreamy';
121
+
122
+ onMount(() => {
123
+ // When the page unmounts, revert to persisted theme
124
+ return () => {
125
+ themeStore.revertPreview();
126
+ };
127
+ });
128
+
129
+ function previewTheme(themeId: string) {
130
+ selectedTheme = themeId;
131
+ // Preview without persisting to localStorage
132
+ themeStore.previewTheme(themeId);
133
+ applyTheme(defaultThemes[themeId]);
134
+ }
135
+
136
+ function saveTheme() {
137
+ // Persist the selection
138
+ themeStore.setTheme(selectedTheme);
139
+ }
140
+ </script>
141
+
142
+ <select onchange={(e) => previewTheme(e.target.value)}>
143
+ {#each Object.entries(defaultThemes) as [id, theme]}
144
+ <option value={id}>{theme.name}</option>
145
+ {/each}
146
+ </select>
147
+
148
+ <button onclick={saveTheme}>Save Theme</button>
149
+ ```
150
+
151
+ ### Store Preview Methods
152
+
153
+ ```typescript
154
+ interface ThemeStore {
155
+ // ... standard methods ...
156
+
157
+ /** Preview a theme temporarily without persisting */
158
+ previewTheme: (themeId: string) => void;
159
+
160
+ /** Revert from preview back to the persisted theme */
161
+ revertPreview: () => void;
162
+
163
+ /** Check if currently in preview mode */
164
+ isPreviewMode: () => boolean;
165
+
166
+ /** Get the persisted theme ID (ignores preview) */
167
+ getPersistedThemeId: () => string;
168
+ }
169
+ ```
170
+
171
+ ## Theme Catalogs
172
+
173
+ For larger applications, you may want to organize themes with metadata. Theme catalogs support:
174
+ - **Active/inactive** - Hide themes from production while keeping them documented
175
+ - **Tags** - Categorize themes (dark, light, seasonal, test, etc.)
176
+ - **Sorting** - Control display order
177
+ - **Filtering** - Show only relevant themes
178
+
179
+ ### Catalog Structure
180
+
181
+ ```typescript
182
+ import type { ThemeCatalog } from 'svelte-theme-picker';
183
+
184
+ const myCatalog: ThemeCatalog = {
185
+ 'brand-dark': {
186
+ theme: { /* theme definition */ },
187
+ meta: {
188
+ active: true, // Show in picker
189
+ tags: ['dark', 'brand'],
190
+ order: 1, // Sort order
191
+ seasonal: false, // Or 'winter', 'spring', etc.
192
+ }
193
+ },
194
+ 'christmas': {
195
+ theme: { /* theme definition */ },
196
+ meta: {
197
+ active: false, // Hidden from picker
198
+ tags: ['dark', 'seasonal'],
199
+ seasonal: 'winter',
200
+ }
201
+ },
202
+ 'test-theme': {
203
+ theme: { /* theme definition */ },
204
+ meta: {
205
+ active: false, // Dev only
206
+ tags: ['test'],
207
+ }
208
+ },
209
+ };
210
+ ```
211
+
212
+ ### Filtering Themes
213
+
214
+ ```typescript
215
+ import {
216
+ defaultThemeCatalog,
217
+ filterCatalog,
218
+ catalogToThemes,
219
+ getActiveThemes,
220
+ getThemesByTag,
221
+ getThemesByAnyTag,
222
+ } from 'svelte-theme-picker';
223
+
224
+ // Get only active themes
225
+ const activeThemes = getActiveThemes(defaultThemeCatalog);
226
+
227
+ // Get themes by tag
228
+ const darkThemes = getThemesByTag(defaultThemeCatalog, 'dark');
229
+
230
+ // Get themes by any of several tags
231
+ const accentThemes = getThemesByAnyTag(defaultThemeCatalog, ['neon', 'vibrant']);
232
+
233
+ // Advanced filtering
234
+ const productionThemes = catalogToThemes(
235
+ filterCatalog(defaultThemeCatalog, {
236
+ activeOnly: true,
237
+ excludeTags: ['test', 'seasonal'],
238
+ })
239
+ );
240
+
241
+ // Use with ThemePicker
242
+ <ThemePicker config={{ themes: productionThemes }} />
243
+ ```
244
+
245
+ ### JSON-Driven Themes
246
+
247
+ Define your themes in a JSON file for easy management and version control:
248
+
249
+ **themes.json**
250
+ ```json
251
+ {
252
+ "brand-dark": {
253
+ "theme": {
254
+ "name": "Brand Dark",
255
+ "description": "Official dark theme",
256
+ "colors": {
257
+ "bgDeep": "#0a0a12",
258
+ "bgMid": "#12121a",
259
+ "bgCard": "#1a1a24",
260
+ "bgGlow": "#2a2a3a",
261
+ "bgOverlay": "#0a0a12",
262
+ "primary1": "#6366f1",
263
+ "primary2": "#818cf8",
264
+ "primary3": "#a5b4fc",
265
+ "primary4": "#c7d2fe",
266
+ "primary5": "#e0e7ff",
267
+ "primary6": "#eef2ff",
268
+ "accent1": "#8b5cf6",
269
+ "accent2": "#a78bfa",
270
+ "accent3": "#c4b5fd",
271
+ "textPrimary": "#f8fafc",
272
+ "textSecondary": "#cbd5e1",
273
+ "textMuted": "#64748b"
274
+ },
275
+ "fonts": {
276
+ "heading": "'Inter', sans-serif",
277
+ "body": "'Inter', sans-serif",
278
+ "mono": "'JetBrains Mono', monospace"
279
+ },
280
+ "effects": {
281
+ "glowColor": "rgba(99, 102, 241, 0.15)",
282
+ "glowIntensity": 0.3,
283
+ "particleColors": ["#6366f1", "#8b5cf6"],
284
+ "useNoise": false,
285
+ "noiseOpacity": 0
286
+ }
287
+ },
288
+ "meta": {
289
+ "active": true,
290
+ "tags": ["dark", "brand", "production"],
291
+ "order": 1
292
+ }
293
+ },
294
+ "christmas-special": {
295
+ "theme": {
296
+ "name": "Christmas",
297
+ "description": "Festive holiday theme"
298
+ },
299
+ "meta": {
300
+ "active": false,
301
+ "tags": ["dark", "seasonal", "holiday"],
302
+ "seasonal": "winter"
303
+ }
304
+ },
305
+ "dev-test": {
306
+ "theme": {
307
+ "name": "Dev Test",
308
+ "description": "Testing theme - not for production"
309
+ },
310
+ "meta": {
311
+ "active": false,
312
+ "tags": ["test", "dev"]
313
+ }
314
+ }
315
+ }
316
+ ```
317
+
318
+ **Loading and filtering for production:**
319
+ ```typescript
320
+ import {
321
+ loadCatalogFromJSON,
322
+ getActiveThemes,
323
+ filterCatalog,
324
+ catalogToThemes,
325
+ } from 'svelte-theme-picker';
326
+
327
+ // Load themes from JSON (e.g., fetched or imported)
328
+ import themesJson from './themes.json';
329
+ const catalog = loadCatalogFromJSON(themesJson);
330
+
331
+ // Get only production-ready themes (active: true)
332
+ const productionThemes = getActiveThemes(catalog);
333
+
334
+ // Or with more advanced filtering
335
+ const productionThemes = catalogToThemes(
336
+ filterCatalog(catalog, {
337
+ activeOnly: true,
338
+ excludeTags: ['test', 'seasonal'],
339
+ })
340
+ );
341
+
342
+ // Use with ThemePicker
343
+ <ThemePicker config={{ themes: productionThemes }} />
344
+ ```
345
+
346
+ This pattern lets you:
347
+ - **Document all themes** in one place (production, test, seasonal)
348
+ - **Control which themes are active** in production via the `active` flag
349
+ - **Filter by tags** for specific use cases (e.g., only dark themes)
350
+ - **Version control** your theme definitions alongside your code
351
+
352
+ ### Catalog Utilities
353
+
354
+ ```typescript
355
+ // Convert simple themes to catalog
356
+ import { themesToCatalog } from 'svelte-theme-picker';
357
+ const catalog = themesToCatalog(myThemes, { active: true });
358
+
359
+ // Merge catalogs (combine default themes with your custom ones)
360
+ import { mergeCatalogs, defaultThemeCatalog } from 'svelte-theme-picker';
361
+ const combined = mergeCatalogs(defaultThemeCatalog, myCatalog);
362
+
363
+ // Get all tags in a catalog
364
+ import { getCatalogTags } from 'svelte-theme-picker';
365
+ const tags = getCatalogTags(myCatalog); // ['dark', 'brand', 'seasonal', ...]
366
+
367
+ // Sort catalog by order
368
+ import { sortCatalog } from 'svelte-theme-picker';
369
+ const sorted = sortCatalog(myCatalog);
370
+ ```
371
+
372
+ ## Custom Theme Picker UI
373
+
374
+ For complete control over the theme picker UI (e.g., in forms), you can build your own using the exported utilities. This is useful when you need color swatches in a specific layout or want to match your app's design system.
375
+
376
+ ### Color Swatch Grid (Form Integration)
377
+
378
+ ```svelte
379
+ <script lang="ts">
380
+ import {
381
+ themeStore,
382
+ applyTheme,
383
+ defaultThemes,
384
+ type Theme
385
+ } from 'svelte-theme-picker';
386
+ import { onMount } from 'svelte';
387
+
388
+ let selectedTheme = $state('dreamy');
389
+
390
+ // Revert to saved theme when leaving the page
391
+ onMount(() => {
392
+ return () => themeStore.revertPreview();
393
+ });
394
+
395
+ function previewTheme(themeId: string) {
396
+ selectedTheme = themeId;
397
+ themeStore.previewTheme(themeId);
398
+ applyTheme(defaultThemes[themeId]);
399
+ }
400
+
401
+ function saveSelection() {
402
+ // Persist the theme when form is submitted
403
+ themeStore.setTheme(selectedTheme);
404
+ }
405
+ </script>
406
+
407
+ <div class="theme-grid">
408
+ {#each Object.entries(defaultThemes) as [id, theme]}
409
+ <button
410
+ type="button"
411
+ class="theme-swatch"
412
+ class:selected={selectedTheme === id}
413
+ onclick={() => previewTheme(id)}
414
+ title={theme.name}
415
+ >
416
+ <div class="colors">
417
+ <span style="background: {theme.colors.primary1}"></span>
418
+ <span style="background: {theme.colors.primary3}"></span>
419
+ <span style="background: {theme.colors.primary5}"></span>
420
+ <span style="background: {theme.colors.accent1}"></span>
421
+ </div>
422
+ <span class="name">{theme.name}</span>
423
+ </button>
424
+ {/each}
425
+ </div>
426
+
427
+ <style>
428
+ .theme-grid {
429
+ display: flex;
430
+ flex-wrap: wrap;
431
+ gap: 8px;
432
+ }
433
+
434
+ .theme-swatch {
435
+ display: flex;
436
+ flex-direction: column;
437
+ align-items: center;
438
+ gap: 4px;
439
+ padding: 8px;
440
+ background: transparent;
441
+ border: 2px solid transparent;
442
+ border-radius: 8px;
443
+ cursor: pointer;
444
+ transition: all 0.2s;
445
+ }
446
+
447
+ .theme-swatch:hover {
448
+ background: rgba(255, 255, 255, 0.05);
449
+ }
450
+
451
+ .theme-swatch.selected {
452
+ border-color: var(--accent-1, #a855f7);
453
+ background: rgba(168, 85, 247, 0.1);
454
+ }
455
+
456
+ .colors {
457
+ display: grid;
458
+ grid-template-columns: repeat(2, 1fr);
459
+ gap: 2px;
460
+ width: 32px;
461
+ height: 32px;
462
+ border-radius: 6px;
463
+ overflow: hidden;
464
+ }
465
+
466
+ .colors span {
467
+ width: 100%;
468
+ height: 100%;
469
+ }
470
+
471
+ .name {
472
+ font-size: 0.75rem;
473
+ color: var(--text-secondary, #a0a0a0);
474
+ }
475
+
476
+ .theme-swatch.selected .name {
477
+ color: var(--text-primary, #fff);
478
+ }
479
+ </style>
480
+ ```
481
+
482
+ ### Using with Forms
483
+
484
+ When integrating theme selection in a form (e.g., collection creation), use the preview pattern:
485
+
486
+ 1. **On mount**: Save the current theme with `getPersistedThemeId()`
487
+ 2. **On selection**: Use `previewTheme()` + `applyTheme()` for live preview
488
+ 3. **On cancel/leave**: Call `revertPreview()` to restore the original theme
489
+ 4. **On save**: Call `setTheme()` to persist the selection
490
+
491
+ ```svelte
492
+ <script>
493
+ import { themeStore, applyTheme, defaultThemes } from 'svelte-theme-picker';
494
+ import { onMount } from 'svelte';
495
+
496
+ let selectedTheme = 'dreamy';
497
+ let formSubmitted = false;
498
+
499
+ onMount(() => {
500
+ // Restore original theme if user leaves without saving
501
+ return () => {
502
+ if (!formSubmitted) {
503
+ themeStore.revertPreview();
504
+ }
505
+ };
506
+ });
507
+
508
+ function selectTheme(themeId) {
509
+ selectedTheme = themeId;
510
+ themeStore.previewTheme(themeId);
511
+ applyTheme(defaultThemes[themeId]);
512
+ }
513
+
514
+ function handleSubmit() {
515
+ formSubmitted = true;
516
+ themeStore.setTheme(selectedTheme);
517
+ // ... save to database
518
+ }
519
+ </script>
520
+ ```
521
+
522
+ ## Custom Themes
523
+
524
+ You can provide your own themes:
525
+
526
+ ```svelte
527
+ <script>
528
+ import { ThemePicker, type Theme } from 'svelte-theme-picker';
529
+
530
+ const myThemes: Record<string, Theme> = {
531
+ 'my-theme': {
532
+ name: 'My Theme',
533
+ description: 'A custom theme',
534
+ colors: {
535
+ bgDeep: '#1a1a2e',
536
+ bgMid: '#232342',
537
+ bgCard: '#2a2a4a',
538
+ bgGlow: '#3d3d6b',
539
+ bgOverlay: '#1a1a2e',
540
+ primary1: '#c9a0dc',
541
+ primary2: '#b8a9d9',
542
+ primary3: '#e8a4c9',
543
+ primary4: '#7eb8da',
544
+ primary5: '#8ad4d4',
545
+ primary6: '#f0c4a8',
546
+ accent1: '#a855f7',
547
+ accent2: '#ff6b9d',
548
+ accent3: '#64c8ff',
549
+ textPrimary: '#e8e0f0',
550
+ textSecondary: '#c8c0d8',
551
+ textMuted: '#9090b0',
552
+ },
553
+ fonts: {
554
+ heading: "'Inter', sans-serif",
555
+ body: "'Inter', sans-serif",
556
+ mono: "'JetBrains Mono', monospace",
557
+ },
558
+ effects: {
559
+ glowColor: 'rgba(168, 85, 247, 0.15)',
560
+ glowIntensity: 0.3,
561
+ particleColors: ['#c9a0dc', '#a855f7'],
562
+ useNoise: false,
563
+ noiseOpacity: 0,
564
+ },
565
+ },
566
+ };
567
+ </script>
568
+
569
+ <ThemePicker config={{ themes: myThemes, defaultTheme: 'my-theme' }} />
570
+ ```
571
+
572
+ ## CSS Variables
573
+
574
+ The theme picker applies these CSS variables to your document:
575
+
576
+ ### Background Colors
577
+ - `--bg-deep` - Deepest background
578
+ - `--bg-mid` - Mid-level background
579
+ - `--bg-card` - Card/surface background
580
+ - `--bg-glow` - Glow/highlight background
581
+ - `--bg-overlay` - Overlay background
582
+
583
+ ### Primary Colors (1-6)
584
+ - `--primary-1` through `--primary-6`
585
+
586
+ ### Accent Colors (1-3)
587
+ - `--accent-1` through `--accent-3`
588
+
589
+ ### Text Colors
590
+ - `--text-primary`
591
+ - `--text-secondary`
592
+ - `--text-muted`
593
+
594
+ ### Fonts
595
+ - `--font-heading`
596
+ - `--font-body`
597
+ - `--font-mono`
598
+
599
+ ### Effects
600
+ - `--shadow-glow`
601
+ - `--glow-color`
602
+ - `--glow-intensity`
603
+
604
+ ## Using the Store Directly
605
+
606
+ ```svelte
607
+ <script>
608
+ import { themeStore, applyTheme, defaultThemes } from 'svelte-theme-picker';
609
+
610
+ // Subscribe to changes
611
+ $effect(() => {
612
+ const theme = defaultThemes[$themeStore];
613
+ if (theme) {
614
+ console.log('Theme changed to:', theme.name);
615
+ }
616
+ });
617
+
618
+ // Change theme programmatically
619
+ function setDarkMode() {
620
+ themeStore.setTheme('mono');
621
+ }
622
+ </script>
623
+ ```
624
+
625
+ ## Built-in Themes
626
+
627
+ | ID | Name | Tags | Description |
628
+ |----|------|------|-------------|
629
+ | `dreamy` | Dreamy | dark, pastel | Soft pastels with dreamy atmosphere |
630
+ | `cyberpunk` | Cyberpunk | dark, neon | High contrast neons |
631
+ | `sunset` | Sunset | dark, warm | Warm oranges and purples |
632
+ | `ocean` | Ocean | dark, cool | Deep blues and teals |
633
+ | `mono` | Mono | dark, minimal | Clean monochromatic |
634
+ | `sakura` | Sakura | dark, pastel | Cherry blossom pinks |
635
+ | `aurora` | Aurora | dark, nature | Northern lights greens and purples |
636
+ | `galaxy` | Galaxy | dark, space | Deep space cosmic colors |
637
+ | `milk` | Milk | light, neutral | Clean, creamy light theme |
638
+ | `light` | Light | light, modern | Modern light theme with purple accents |
639
+
640
+ ## Performance
641
+
642
+ The theme picker is optimized to minimize impact on your application:
643
+
644
+ - **CSS Containment**: Uses `contain: layout style` to isolate rendering
645
+ - **GPU Acceleration**: Animations use `transform` and `will-change` for smooth 60fps transitions
646
+ - **Batched DOM Updates**: Theme changes use `requestAnimationFrame` to batch all CSS variable updates into a single paint frame
647
+ - **Efficient Transitions**: Only animates specific properties instead of `transition: all`
648
+ - **Minimal Bundle**: ~75KB total including all 10 built-in themes
649
+
650
+ ## Accessibility
651
+
652
+ The component follows accessibility best practices:
653
+
654
+ - **Keyboard Navigation**: Press `Escape` to close the theme panel
655
+ - **ARIA Labels**: All interactive elements have proper `aria-label` attributes
656
+ - **Role Attributes**: Theme list uses `role="listbox"` with `role="option"` items
657
+ - **Focus Management**: Proper `aria-expanded` and `aria-haspopup` on trigger button
658
+ - **Screen Reader Support**: Decorative elements marked with `aria-hidden="true"`
659
+
660
+ ## Browser Support
661
+
662
+ - Modern browsers (Chrome, Firefox, Safari, Edge)
663
+ - Safari: Full support including `-webkit-backdrop-filter` for blur effects
664
+ - Graceful degradation: localStorage errors are handled silently (e.g., private browsing)
665
+
666
+ ## TypeScript
667
+
668
+ Full TypeScript support with exported types:
669
+
670
+ ```typescript
671
+ import type {
672
+ Theme,
673
+ ThemeColors,
674
+ ThemeFonts,
675
+ ThemeEffects,
676
+ ThemePickerConfig,
677
+ ThemeCatalog,
678
+ ThemeCatalogEntry,
679
+ ThemeMeta,
680
+ ThemeFilterOptions,
681
+ } from 'svelte-theme-picker';
682
+ ```
683
+
684
+ ## License
685
+
686
+ MIT