aberdeen 1.6.0 → 1.7.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/skill/SKILL.md CHANGED
@@ -12,7 +12,7 @@ Aberdeen is a reactive UI library using fine-grained reactivity via JavaScript P
12
12
  3. **Pass observables directly** - Use `text=', ref(obj, 'key')` to avoid parent scope subscriptions
13
13
  4. **Use `onEach` for lists** - Never iterate proxy arrays with `for`/`map` in render functions
14
14
  5. **Class instances are great** - Better than plain objects for typed, structured state
15
- 6. **CSS shortcuts** - Use `@3`, `@4` for spacing (1rem, 2rem), `@primary` for colors
15
+ 6. **CSS shortcuts** - Use $3, $4 for spacing (1rem, 2rem), $primary for colors
16
16
  7. **Minimal scopes** - Smaller reactive scopes = fewer DOM updates
17
17
 
18
18
  # Obtaining info
@@ -21,7 +21,7 @@ The complete tutorial follows below. For detailed API reference open these files
21
21
 
22
22
  - **[aberdeen](aberdeen.md)** - Core: `$`, `proxy`, `onEach`, `ref`, `derive`, `map`, `multiMap`, `partition`, `count`, `isEmpty`, `peek`, `dump`, `clean`, `insertCss`, `insertGlobalCss`, `mount`, `runQueue`, `darkMode`
23
23
  - **[route](route.md)** - Routing: `current`, `go`, `push`, `back`, `up`, `persistScroll`
24
- - **[dispatcher](dispatcher.md)** - Path matching: `Dispatcher`, `matchRest`, `matchFailed`
24
+ - **[dispatcher](dispatcher.md)** - Path matching: `Dispatcher`, `MATCH_REST`, `MATCH_FAILED`
25
25
  - **[transitions](transitions.md)** - Animations: `grow`, `shrink`
26
26
  - **[prediction](prediction.md)** - Optimistic UI: `applyPrediction`, `applyCanon`
27
27
 
@@ -82,30 +82,26 @@ Note that the example is interactive - try typing something!
82
82
 
83
83
  ## Inline styles
84
84
 
85
- To set inline CSS styles on elements, use the `property:value` or `property:`, value syntax:
85
+ To set inline CSS styles on elements, use the `property:value` (short form) or `property: value containing spaces;` (long form) syntax:
86
86
 
87
87
  ```javascript
88
- $('div.box color:red backgroundColor:yellow#Styled text');
88
+ $('p color:red padding:8px background-color:#a882 border: 2px solid #a884; #Styled text');
89
89
  ```
90
90
 
91
- ## CSS shortcuts
92
-
93
- Aberdeen provides shortcuts for commonly used CSS properties, making your code more concise.
94
-
95
91
  ### Property shortcuts
96
92
 
97
- Common property names are automatically expanded:
93
+ Aberdeen provides shortcuts for commonly used CSS properties, making your code more concise.
98
94
 
99
95
  | Shortcut | Expands to |
100
96
  |----------|------------|
101
- | `m`, `mt`, `mb`, `ml`, `mr` | `margin`, `marginTop`, `marginBottom`, `marginLeft`, `marginRight` |
97
+ | `m`, `mt`, `mb`, `ml`, `mr` | `margin`, `margin-top`, `margin-bottom`, `margin-left`, `margin-right` |
102
98
  | `mv`, `mh` | Vertical (top+bottom) or horizontal (left+right) margins |
103
- | `p`, `pt`, `pb`, `pl`, `pr` | `padding`, `paddingTop`, `paddingBottom`, `paddingLeft`, `paddingRight` |
99
+ | `p`, `pt`, `pb`, `pl`, `pr` | `padding`, `padding-top`, `padding-bottom`, `padding-left`, `padding-right` |
104
100
  | `pv`, `ph` | Vertical or horizontal padding |
105
101
  | `w`, `h` | `width`, `height` |
106
102
  | `bg` | `background` |
107
103
  | `fg` | `color` |
108
- | `r` | `borderRadius` |
104
+ | `r` | `border-radius` |
109
105
 
110
106
  ```javascript
111
107
  $('div mv:10px ph:20px bg:lightblue r:10% #Styled box');
@@ -130,7 +126,8 @@ $('button bg:$danger fg:$textLight #Danger');
130
126
 
131
127
  The above generates CSS like `background: var(--primary)` and automatically injects a `:root` style defining the actual values. Since this uses native CSS custom properties, changes to `cssVars` automatically propagate to all elements using those values.
132
128
 
133
- #### Predefined spacing
129
+ ### Spacing variables
130
+
134
131
  You can optionally initialize `cssVars` with keys `1` through `12` mapping to an exponential `rem` scale using {@link aberdeen.setSpacingCssVars}. Since CSS custom property names can't start with a digit, numeric keys are prefixed with `m` (e.g., `$3` becomes `var(--m3)`):
135
132
 
136
133
  ```javascript
@@ -367,7 +364,7 @@ const randomWord = () => Math.random().toString(36).substring(2, 12).replace(/[0
367
364
 
368
365
  $('button text="Add item" click=', () => pairs[randomWord()] = randomWord());
369
366
 
370
- $('div.row.wide marginTop:1em', () => {
367
+ $('div.row.wide margin-top:1em', () => {
371
368
  $('div.box#By key', () => {
372
369
  onEach(pairs, (value, key) => {
373
370
  $(`li#${key}: ${value}`)
@@ -419,22 +416,25 @@ $('div.box', () => {
419
416
  ## CSS
420
417
  Through the {@link aberdeen.insertCss} function, Aberdeen provides a way to create component-local CSS.
421
418
 
419
+ For simple single-element styles, you can pass a string directly:
420
+
422
421
  ```javascript
423
- import { $, proxy, insertCss } from 'aberdeen';
422
+ import { $, insertCss } from 'aberdeen';
423
+
424
+ const simpleCard = insertCss("bg:#f0f0f0 p:$3 r:8px");
425
+ $('div', simpleCard, '#Card content');
426
+ ```
427
+
428
+ For more complex styles with nested selectors, pass an object where each key is a selector and each value is a style string using the same `property:value` syntax as inline styles:
429
+
430
+ ```javascript
431
+ import { $, insertCss } from 'aberdeen';
424
432
 
425
433
  // Create a CSS class that can be applied to elements
426
434
  const myBoxStyle = insertCss({
427
- borderColor: '#6936cd',
428
- backgroundColor: '#1b0447',
429
- button: {
430
- backgroundColor: '#6936cd',
431
- border: 0,
432
- transition: 'box-shadow 0.3s',
433
- boxShadow: '0 0 4px #ff6a0044',
434
- '&:hover': {
435
- boxShadow: '0 0 16px #ff6a0088',
436
- }
437
- }
435
+ "&": "border-color:#6936cd background-color:#1b0447",
436
+ "button": "background-color:#6936cd border:0 transition: box-shadow 0.3s; box-shadow: 0 0 4px #ff6a0044;",
437
+ "button:hover": "box-shadow: 0 0 16px #ff6a0088;"
438
438
  });
439
439
 
440
440
  // myBoxStyle is now something like ".AbdStl1", the name for a generated CSS class.
@@ -442,7 +442,9 @@ const myBoxStyle = insertCss({
442
442
  $('div.box', myBoxStyle, 'button#Click me');
443
443
  ```
444
444
 
445
- This allows you to create single-file components with advanced CSS rules. The {@link aberdeen.insertGlobalCss} function can be used to add CSS without a class prefix.
445
+ The `"&"` selector refers to the element with the generated class itself. Child selectors like `"button"` are scoped to descendants of that element, while pseudo-selectors like `"&:hover"` apply to the element itself.
446
+
447
+ This allows you to create single-file components with advanced CSS rules. The {@link aberdeen.insertGlobalCss} function can be used to add CSS without a class prefix - it accepts the same string or object syntax.
446
448
 
447
449
  Both functions support the same CSS shortcuts and variables as inline styles (see above). For example:
448
450
 
@@ -450,13 +452,8 @@ Both functions support the same CSS shortcuts and variables as inline styles (se
450
452
  import { cssVars, insertGlobalCss } from 'aberdeen';
451
453
  cssVars.boxBg = '#f0f0e0';
452
454
  insertGlobalCss({
453
- body: {
454
- m: 0, // Using shortcut for margin
455
- },
456
- form: {
457
- bg: "$boxBg", // Using background shortcut and CSS variable
458
- mv: "$3", // Set vertical margin to predefined spacing value $3 (1rem)
459
- }
455
+ "body": "m:0", // Using shortcut for margin
456
+ "form": "bg:$boxBg mv:$3" // Using background shortcut, CSS variable, and spacing value
460
457
  });
461
458
  ```
462
459
 
@@ -467,27 +464,20 @@ Aberdeen allows you to easily apply transitions on element creation and element
467
464
 
468
465
  ```javascript
469
466
  let titleStyle = insertCss({
470
- transition: "all 1s ease-out",
471
- transformOrigin: "top left",
472
- "&.faded": {
473
- opacity: 0,
474
- },
475
- "&.imploded": {
476
- transform: "scale(0.1)",
477
- },
478
- "&.exploded": {
479
- transform: "scale(5)",
480
- },
467
+ "&": "transition: all 1s ease-out; transform-origin: left center;",
468
+ "&.faded": "opacity:0",
469
+ "&.imploded": "transform:scale(0.1)",
470
+ "&.exploded": "transform:scale(5)"
481
471
  });
482
472
 
483
473
  const show = proxy(true);
484
474
  $('label', () => {
485
- $('input', {type: 'checkbox', bind: show});
475
+ $('input type=checkbox bind=', show);
486
476
  $('#Show title');
487
477
  });
488
478
  $(() => {
489
479
  if (!show.value) return;
490
- $('h2#(Dis)appearing text', titleStyle, 'create=.faded.imploded destroy=.faded.exploded');
480
+ $('h2#(Dis)appearing text', titleStyle, 'create=faded.imploded destroy=faded.exploded');
491
481
  });
492
482
  ```
493
483
 
@@ -542,46 +532,25 @@ const data = proxy({ a: 1, b: 2 });
542
532
  $(() => {
543
533
  // This scope only re-runs when data.a changes
544
534
  // Changes to data.b won't trigger a re-render
545
- const b = peek(data, 'b');
546
- console.log(`A is ${data.a}, B was ${b} when A changed.`);
535
+ $(`h2#a == ${data.a} && b == ${peek(data, 'b')}`);
547
536
  });
548
537
 
549
- $('button text="Change B" click=', () => data.b++); // Won't log
550
- $('button text="Change A" click=', () => data.a++); // Will log
538
+ $(`button text="a++ (will update)" click=`, () => data.a++);
539
+ $(`button ml:1rem text="b++ (won't update)" click=`, () => data.b++);
551
540
  ```
552
541
 
553
542
  You can also pass a function to `peek()` to execute it without any subscriptions:
554
543
 
555
544
  ```javascript
556
- const count = peek(() => data.a + data.b); // Reads both without subscribing
545
+ const a = proxy(42);
546
+ const b = proxy(7);
547
+ const sum = peek(() => a.value + b.value); // Reads both without subscribing
548
+ $('#Sum is: '+sum);
549
+ setInterval(() => a.value++, 1000); // Won't update
557
550
  ```
558
551
 
559
552
  This can be useful to avoid rerenders (of even rerender loops) when you only need a point-in-time snapshot of some reactive data.
560
553
 
561
- ## Debugging with dump()
562
-
563
- The {@link aberdeen.dump} function creates a live, interactive tree view of any data structure in the DOM. It's particularly useful for debugging reactive state:
564
-
565
- ```javascript
566
- import { $, proxy, dump } from 'aberdeen';
567
-
568
- const state = proxy({
569
- user: { name: 'Frank', kids: 1 },
570
- items: ['a', 'b']
571
- });
572
-
573
- $('h2#Live State Dump');
574
- dump(state);
575
-
576
- // The dump updates automatically as state changes
577
- $('button text="Update state" click=', () => {
578
- state.user.kids++;
579
- state.items.push('new');
580
- });
581
- ```
582
-
583
- The dump renders recursively using `<ul>` and `<li>` elements, showing all properties and their values. It updates reactively when any proxied data changes. It is intended for debugging, though with some CSS styling you may find it useful in some simple real-world scenarios as well.
584
-
585
554
  ## Derived values
586
555
  An observer scope doesn't *need* to create DOM elements. It may also perform other side effects, such as modifying other observable objects. For instance:
587
556
 
@@ -645,6 +614,31 @@ $(() => {
645
614
  })
646
615
  ```
647
616
 
617
+ ## Debugging with dump()
618
+
619
+ The {@link aberdeen.dump} function creates a live, interactive tree view of any data structure in the DOM. It's particularly useful for debugging reactive state:
620
+
621
+ ```javascript
622
+ import { $, proxy, dump } from 'aberdeen';
623
+
624
+ const state = proxy({
625
+ user: { name: 'Frank', kids: 1 },
626
+ items: ['a', 'b']
627
+ });
628
+
629
+ $('h2#Live State Dump');
630
+ dump(state);
631
+
632
+ // The dump updates automatically as state changes
633
+ $('button text="Update state" click=', () => {
634
+ state.user.kids++;
635
+ state.items.push('new');
636
+ });
637
+ ```
638
+
639
+ The dump renders recursively using `<ul>` and `<li>` elements, showing all properties and their values. It updates reactively when any proxied data changes. It is intended for debugging, though with some CSS styling you may find it useful in some simple real-world scenarios as well.
640
+
641
+
648
642
  ## html-to-aberdeen
649
643
 
650
644
  Sometimes, you want to just paste a largish block of HTML into your application (and then maybe modify it to bind some actual data). Having to translate HTML to `$` calls manually is little fun, so there's a tool for that:
@@ -657,6 +651,222 @@ It takes HTML on stdin (paste it and press `ctrl-d` for end-of-file), and output
657
651
 
658
652
  > Caveat: This tool has been vibe coded (thanks Claude!) with very little code review. As it doesn't use the filesystem nor the network, I'd say it's safe to use though! :-) Also, it happens to work pretty well.
659
653
 
654
+ ## Routing
655
+
656
+ Aberdeen provides an optional built-in router via the {@link route} module. The router is reactive and integrates seamlessly with browser history.
657
+
658
+ The {@link route.current} object is an observable that reflects the current URL:
659
+
660
+ ```javascript
661
+ import { $ } from 'aberdeen';
662
+ import * as route from 'aberdeen/route';
663
+
664
+ $(() => {
665
+ $(`p#Path string: ${route.current.path}`); // eg "/example/123"
666
+ $(`p#Path segments: ${JSON.stringify(route.current.p)}`); // eg ["example", "123"]
667
+ });
668
+ ```
669
+
670
+ To navigate programmatically, use {@link route.go}:
671
+
672
+ ```javascript
673
+ import { $ } from 'aberdeen';
674
+ import * as route from 'aberdeen/route';
675
+ console.log('pn', location.protocol, location.host, location.hostname, location.pathname);
676
+
677
+ $('button#Go to settings', {
678
+ click: () => route.go('/settings')
679
+ });
680
+
681
+ // Or using path segments
682
+ $('button ml:1rem #Go to user 123', {
683
+ click: () => route.go({p: ['users', 123]})
684
+ });
685
+ ```
686
+
687
+ For convenience, you can call {@link route.interceptLinks} once to automatically convert clicks on local `<a>` tags into Aberdeen routing, so you can use regular anchor tags without manual click handlers. Example: `$('a href=/settings text=Settings')`.
688
+
689
+ ```javascript
690
+ import { $ } from 'aberdeen';
691
+ import * as route from 'aberdeen/route';
692
+
693
+ route.interceptLinks(); // Just once on startup:
694
+
695
+ $('a role=button href=/settings #Go to settings')
696
+ ```
697
+
698
+ The {@link route.push} function is useful for overlays that should be closeable with browser back:
699
+
700
+ ```javascript
701
+ import { $ } from 'aberdeen';
702
+ import * as route from 'aberdeen/route';
703
+
704
+ $('button#Open modal', {
705
+ click: () => route.push({state: {modal: 'settings'}})
706
+ });
707
+
708
+ $(() => {
709
+ if (!route.current.state.modal) return;
710
+ $('div.modal-overlay', {
711
+ click: () => route.back({state: {modal: undefined}})
712
+ }, () => {
713
+ $('div.modal#Modal content here');
714
+ });
715
+ });
716
+ ```
717
+
718
+ Optionally, you can use the {@link dispatcher.Dispatcher} class for declarative routing. It allows you to register route patterns with associated handler functions, which are invoked when the current route matches the pattern. It can match typed parameters and rest parameters.
719
+
720
+ ## Prediction
721
+
722
+ When building interactive applications with client-server communication, Aberdeen's prediction system allows for optimistic UI updates. The {@link prediction.applyPrediction} function records changes to any proxied objects made within its callback. These changes are treated as *predictions* that may later be confirmed or reverted based on server responses. When a server response arrives, the {@link prediction.applyCanon} function applies authoritative changes from the server, reverting any conflicting predictions while attempting to reapply non-conflicting ones.
723
+
724
+ ## Full Example: Multi-page App
725
+
726
+ Here's a complete example (a contact manager) demonstrating routing, state management, CSS, dark mode, and dynamic content:
727
+
728
+ ```typescript
729
+ import { $, proxy, onEach, cssVars, ref, darkMode, insertCss, insertGlobalCss, setSpacingCssVars, map } from 'aberdeen';
730
+ import * as route from 'aberdeen/route';
731
+ import { Dispatcher } from 'aberdeen/dispatcher';
732
+ import { grow, shrink } from 'aberdeen/transitions';
733
+
734
+ class Contact {
735
+ constructor(
736
+ public id: number,
737
+ public firstName: string,
738
+ public lastName: string,
739
+ public email: string,
740
+ public phone: string
741
+ ) {}
742
+ }
743
+
744
+ // Enable link interception for SPA navigation
745
+ route.interceptLinks();
746
+
747
+ // Initialize $1-$12 CSS variables for consistent spacing ($2=0.5rem, $3=1rem, $4=2rem, etc.)
748
+ setSpacingCssVars();
749
+
750
+ // Reactive theme based on system preference
751
+ $(() => {
752
+ cssVars.primary = '#2563eb';
753
+ cssVars.bg = darkMode() ? '#0f172a' : '#ffffff';
754
+ cssVars.fg = darkMode() ? '#e2e8f0' : '#1e293b';
755
+ cssVars.cardBg = darkMode() ? '#1e293b' : '#f8fafc';
756
+ cssVars.border = darkMode() ? '#334155' : '#e2e8f0';
757
+ });
758
+
759
+ // Global styles for semantic HTML elements that apply everywhere
760
+ insertGlobalCss({
761
+ "*": "m:0 p:0",
762
+ "body": "bg:$bg fg:$fg font-family: system-ui, sans-serif;",
763
+ "a": "color:$primary text-decoration:none",
764
+ "a:hover": "text-decoration:underline",
765
+ "a[role=button]": "bg:$primary fg:white r:8px p:$2",
766
+ });
767
+
768
+ // Application state
769
+ const contacts = proxy([
770
+ new Contact(1, 'Emma', 'Wilson', 'emma.wilson@email.com', '555-0101'),
771
+ new Contact(2, 'James', 'Anderson', 'j.anderson@email.com', '555-0102'),
772
+ new Contact(3, 'Sofia', 'Martinez', 'sofia.m@email.com', '555-0103'),
773
+ new Contact(4, 'Liam', 'Brown', 'liam.brown@email.com', '555-0104')
774
+ ]);
775
+
776
+ // Router setup
777
+ const dispatcher = new Dispatcher();
778
+ dispatcher.addRoute(drawHome);
779
+ dispatcher.addRoute('contacts', drawContactList);
780
+ dispatcher.addRoute('contacts', Number, drawContactDetail);
781
+
782
+ // Main app
783
+ $('div.app', () => {
784
+ $('nav display:flex gap:$3 p:$3 border-bottom: 1px solid $border;', () => {
785
+ $('a href=/ text=Home font-weight:', route.current.p.length === 0 ? 'bold' : 'normal');
786
+ $('a href=/contacts text=Contacts font-weight:', route.current.p[0] === 'contacts' ? 'bold' : 'normal');
787
+ });
788
+ $('main p:$3', () => dispatcher.dispatch(route.current.p));
789
+ });
790
+
791
+ function drawHome() {
792
+ $('h1#Contact Manager');
793
+ $('p#A modern contact list with search, sort, and dark mode support.');
794
+ }
795
+
796
+ // Contact card styles
797
+ const cardStyle = insertCss({
798
+ "&": "bg:$cardBg border: 1px solid $border; r:8px p:$3 mv:$2 display:block transition: transform 0.2s;",
799
+ "&:hover": "transform:translateX(4px)",
800
+ "a&": "color:inherit;",
801
+ });
802
+
803
+ const filterStyle = insertCss({
804
+ "&": "display:flex gap:$3 mv:$3",
805
+ "> *": "p:$2 r:4px bg:$bg fg:$fg border: 1px solid $border;",
806
+ });
807
+
808
+ function drawContactList() {
809
+ $('h1#Contacts');
810
+
811
+ // Search and sort controls
812
+ $('div', filterStyle, () => {
813
+ $('input flex:1 placeholder="Search contacts..." bind=', ref(route.current.search, 'q'));
814
+ $('select bind=', ref(route.current.search, 'sort'), () => {
815
+ $('option value=firstName #First Name');
816
+ $('option value=lastName #Last Name');
817
+ $('option value=email #Email');
818
+ });
819
+ });
820
+
821
+ // Contact list
822
+ $('div', () => {
823
+ const sortBy = route.current.search.sort || 'firstName';
824
+
825
+ const filtered = map(contacts, contact => {
826
+ const query = route.current.search.q;
827
+ if (query) {
828
+ const info = `${contact.firstName} ${contact.lastName} ${contact.email}`;
829
+ if (!info.toLowerCase().includes(query.toLowerCase())) return; // Skip!
830
+ }
831
+ return contact;
832
+ });
833
+
834
+ onEach(filtered, contact => {
835
+ $('a', cardStyle, 'create=', grow, 'destroy=', shrink, `href=/contacts/${contact.id}`, () => {
836
+ $('h2', () => {
837
+ $('span font-weight:normal text=', contact.firstName+" ");
838
+ $('span text=', contact.lastName);
839
+ });
840
+ $('div text=', contact.email);
841
+ });
842
+ }, contact => contact[sortBy].toLowerCase());
843
+
844
+ $(`a role=button mt:$3 text="Add new contact" href=/contacts/${contacts.length}`);
845
+ });
846
+ }
847
+
848
+ // Detail form styles
849
+ const detailStyle = insertCss({
850
+ "&": "bg:$cardBg border: 1px solid $border; r:8px p:$4 max-width:600px",
851
+ "label": "display:block font-weight:600 mt:$3 mb:$2",
852
+ "input": "w:100% p:$2 r:4px border: 1px solid $border; bg:$bg fg:$fg"
853
+ });
854
+
855
+ function drawContactDetail(id: number) {
856
+ const contact = contacts[id] ||= {};
857
+
858
+ $('a role=button href=/contacts #← Back');
859
+
860
+ $('div mt:$3', detailStyle, () => {
861
+ $('h2 mb:$2 text=', ref(contact, 'firstName'), 'text=', ' ', 'text=', ref(contact, 'lastName'));
862
+ $('label text="First Name" input bind=', ref(contact, 'firstName'));
863
+ $('label text="Last Name" input bind=', ref(contact, 'lastName'));
864
+ $('label text="Email" input type=email bind=', ref(contact, 'email'));
865
+ $('label text="Phone" input type=tel bind=', ref(contact, 'phone'));
866
+ });
867
+ }
868
+ ```
869
+
660
870
  ## Further reading
661
871
 
662
872
  If you've understood all/most of the above, you should be ready to get going with Aberdeen! You may also find these links helpful: