@usableapp/cardds 0.6.8 → 0.6.10

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/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  > For: a project that consumes `@usableapp/cardds` and its AI — what changed between versions, written as before → after, so a screen can be brought up to date without reading the source. Newest first. A consumer never edits cardds; if something here leaves you without a way to do what you did before, report the gap.
4
4
 
5
+ ## 0.6.10 — 2026-09-23
6
+
7
+ ### New template: `CallSheet` (layout-3)
8
+ - No top · content = a `Step` holding ONE `Page` (media · `heading`, the page's h1 · `body` · one button, `action`) plus
9
+ the `Sheet` that button calls · no bottom.
10
+ - The page starts at the lead (15%).
11
+ - The sheet waits `away`; the button brings it up full; its handle takes it down to peek (`states`, default "peek full").
12
+ Its body is rendered only while it is up.
13
+ - State: `open` + `onOpenChange`, or `defaultOpen`. `sheet={{ title, body, foot }}`.
14
+ - Gallery: layout-3, and screen → Install.
15
+
16
+ ### Changed
17
+ - **`Route`:** both ends and the plane share one grid, so the codes, the dotted runs and the plane sit on one line even
18
+ when a place name wraps on one side.
19
+ - **`Bubble`:** runs to 75% of the thread (knob `--bubble-max-w`, was 88% incl. the actions). The gallery stories no
20
+ longer show actions.
21
+ - **`Wave`:** rounded bars, one width, heights from `levels` (0–1; default a mock-up shape). Only whole bars show,
22
+ spread edge to edge. Knobs `--wave-bar-width` (2px), `--wave-gap` (2px), `--wave-h`.
23
+
24
+ ## 0.6.9 — 2026-09-23
25
+
26
+ ### Walkthrough (Lh 2026-09-23)
27
+ - **Swipe:** a sideways swipe turns the page (left = next, right = back). The walk still scrolls up and down; a drag must be mostly sideways and at least 48px to count.
28
+ - **Page turn:** the new page slides in from the side as itself, unscrolled. There is no vertical glide, because the leaving and arriving pages now have two separate transition names (`centre-card-out` / `centre-card-in`, was one `centre-card`).
29
+ - **Picture:** a page that opens on a picture shows it full width from the top edge, as tall as the lead (15%) + `--base-content-media-h`; the words start where they did.
30
+ - **Width:** the walk's scroll box is the whole content, edge to edge. The words keep the screen's inset inside each `Page`.
31
+ - **Scroll room:** each page's bottom padding is exactly the bottom layer's height (`--bottom-h`). A page scrolls only when the layer would cover words; at 100% text nothing scrolls.
32
+
5
33
  ## 0.6.8 — 2026-09-23
6
34
 
7
35
  - **`Walkthrough`: a new page starts at its lead.** On a page turn (the pager, or the app changing `at`), the walk scrolls back to the top, so the next page never opens where the last one was scrolled to (Lh 2026-09-23).
package/css/card.css CHANGED
@@ -236,21 +236,21 @@
236
236
  <section class="page" inert>…</section>
237
237
  </div></section> <div class="action-bar action-bar--pager">← →</div> */
238
238
  ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
239
- ::view-transition-old(centre-card), ::view-transition-new(centre-card) {
239
+ ::view-transition-old(centre-card-out), ::view-transition-new(centre-card-in) {
240
240
  animation-duration: var(--motion-slide);
241
241
  animation-timing-function: ease-out;
242
242
  mix-blend-mode: normal;
243
243
  }
244
- :root[data-slide="next"]::view-transition-old(centre-card) { animation-name: centre-card-out-left; }
245
- :root[data-slide="next"]::view-transition-new(centre-card) { animation-name: centre-card-in-right; }
246
- :root[data-slide="back"]::view-transition-old(centre-card) { animation-name: centre-card-out-right; }
247
- :root[data-slide="back"]::view-transition-new(centre-card) { animation-name: centre-card-in-left; }
244
+ :root[data-slide="next"]::view-transition-old(centre-card-out) { animation-name: centre-card-out-left; }
245
+ :root[data-slide="next"]::view-transition-new(centre-card-in) { animation-name: centre-card-in-right; }
246
+ :root[data-slide="back"]::view-transition-old(centre-card-out) { animation-name: centre-card-out-right; }
247
+ :root[data-slide="back"]::view-transition-new(centre-card-in) { animation-name: centre-card-in-left; }
248
248
  @keyframes centre-card-out-left { to { translate: -100% 0; opacity: 0; } }
249
249
  @keyframes centre-card-out-right { to { translate: 100% 0; opacity: 0; } }
250
250
  @keyframes centre-card-in-right { from { translate: 100% 0; opacity: 0; } }
251
251
  @keyframes centre-card-in-left { from { translate: -100% 0; opacity: 0; } }
252
252
  @media (prefers-reduced-motion: reduce) {
253
- ::view-transition-old(centre-card), ::view-transition-new(centre-card) { animation: none; }
253
+ ::view-transition-old(centre-card-out), ::view-transition-new(centre-card-in) { animation: none; }
254
254
  }
255
255
  /* the page dots under the card sit centred under it — the indicator belongs
256
256
  with the page that changes, never in the bar (Lh, 2026-09-12) */
package/css/journey.css CHANGED
@@ -5,15 +5,21 @@
5
5
  ============================================================ */
6
6
 
7
7
  /* ---- route: endpoint · dotted connector (icon in middle) · endpoint ---- */
8
- .route { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; column-gap: var(--route-col-gap, var(--sp-1)); } /* line → icon: --sp-1, as the via always had; code → line is the code row's own --sp-3 */
8
+ .route { display: grid; grid-template-columns: 1fr auto 1fr; grid-template-rows: auto auto auto; align-items: end; column-gap: var(--route-col-gap, var(--sp-1)); row-gap: var(--route-point-gap, var(--sp-0)); }
9
+ /* ONE grid for both ends (Lh 2026-09-23): place · code · time are rows shared by the two ends, so the codes — and the dotted runs
10
+ on them — sit on one line even when a long place name wraps on one side; the plane sits on that code row */ /* line → icon: --sp-1, as the via always had; code → line is the code row's own --sp-3 */
9
11
  /* the two endpoints get EQUAL columns whatever their words measure, so the via
10
12
  (the icon alone) sits at the card's centre; each dotted run lives on ITS
11
13
  endpoint's code row and fills that side's leftover — with short names the
12
14
  runs are long and equal, a long place name wraps in its half instead of
13
15
  pushing the plane (Lh 2026-09-16) */
14
16
 
15
- .route__point { display: grid; gap: var(--route-point-gap, var(--sp-0)); min-width: 0; }
16
- .route__point--end { text-align: right; }
17
+ .route__point { display: contents; }
18
+ .route__point > * { grid-column: 1; min-width: 0; }
19
+ .route__point--end > * { grid-column: 3; text-align: right; }
20
+ .route__place { grid-row: 1; }
21
+ .route__code { grid-row: 2; align-self: center; }
22
+ .route__time { grid-row: 3; align-self: start; }
17
23
 
18
24
  .route__code { font: var(--route-code-font, var(--type-h2)); letter-spacing: var(--track-h2); display: flex; align-items: center; gap: var(--route-code-gap, var(--sp-3)); }
19
25
  .route__code::after, .route__point--end .route__code::before { content: ""; flex: 1; border-top: var(--rule-w) dotted currentColor; opacity: 0.65; }
@@ -22,7 +28,7 @@
22
28
  .route__place { font: var(--route-place-font, var(--type-caption)); color: var(--route-place-ink, var(--card-muted)); }
23
29
  .card--3 .route__place { color: inherit; opacity: 0.7; }
24
30
 
25
- .route__via { display: flex; align-items: center; opacity: 0.65; font-size: var(--route-via-font-size, var(--fs-body)); }
31
+ .route__via { grid-column: 2; grid-row: 2; align-self: center; display: flex; align-items: center; opacity: 0.65; font-size: var(--route-via-font-size, var(--fs-body)); }
26
32
 
27
33
  /* ---- tile badge: tiny label over a big value (gate, seat, zone) ---- */
28
34
  .tile-badge {
package/css/lists.css CHANGED
@@ -113,7 +113,7 @@
113
113
  justify-content: start;
114
114
  align-items: center;
115
115
  gap: var(--sp-1) var(--sp-3);
116
- max-width: 88%;
116
+ max-width: var(--bubble-max-w, 75%); /* a message may run to three quarters of the thread (Lh 2026-09-23) */
117
117
  margin-right: auto; /* theirs: hugs the left */
118
118
  }
119
119
  .bubble--right { grid-template-columns: auto minmax(0, max-content); justify-content: end; margin-right: 0; margin-left: auto; } /* mine: hugs the right, the actions on its left */
package/css/media.css CHANGED
@@ -132,14 +132,25 @@
132
132
  /* ---- wave: audio waveform stand-in for voice rows ----
133
133
  <span class="wave"></span> — sits in a .row__body, fills the width */
134
134
  .wave {
135
- display: block;
135
+ /* only WHOLE bars show (Lh 2026-09-23 — a clipped last bar read thinner): the columns are as many whole bar-widths as fit
136
+ (auto-fill), spread edge to edge; bars that do not fit go to rows of no height, clipped away */
137
+ display: grid;
138
+ grid-template-columns: repeat(auto-fit, var(--wave-bar-width, 0.125rem)); /* auto-FIT: columns no bar fills collapse, so a wide row still spreads its bars edge to edge */
139
+ grid-template-rows: 100%;
140
+ grid-auto-rows: 0;
141
+ justify-content: space-between;
142
+ align-items: center;
143
+ column-gap: var(--wave-gap, 0.125rem);
136
144
  height: var(--wave-h, var(--icon));
137
145
  width: 100%;
138
- background: repeating-linear-gradient(90deg, currentColor 0 var(--sp-1), transparent var(--sp-1) var(--sp-2));
139
- /* masks only read alpha: currentColor = "opaque" without naming a color */
140
- mask: radial-gradient(ellipse 60% 100% at 50% 50%, currentColor 55%, transparent 58%) 0 0 / var(--tap-lg) 100% repeat-x;
141
- -webkit-mask: radial-gradient(ellipse 60% 100% at 50% 50%, currentColor 55%, transparent 58%) 0 0 / var(--tap-lg) 100% repeat-x;
142
- opacity: 0.8;
146
+ overflow: hidden;
147
+ }
148
+ /* a bar: as tall as its level (--wave-at, 0–1 — DATA, set by Wave), rounded ends, the ink of whatever it sits on (Lh 2026-09-23) */
149
+ .wave > i {
150
+ width: 100%; /* its column: every bar one width, only the height tells the level (Lh 2026-09-23) */
151
+ height: calc(var(--wave-at, 0.5) * 100%);
152
+ border-radius: var(--r-chip);
153
+ background: currentColor;
143
154
  }
144
155
 
145
156
  /* ---- mosaic: one big photo + two small, inside a card ----
package/css/step.css CHANGED
@@ -107,11 +107,12 @@
107
107
  (the picture is never pinned). Each page starts at the screen's lead and ends with the same room under it — the lead as
108
108
  padding above AND below — so the words begin near the middle and, when a big text size runs them long, scroll up clear.
109
109
  The walk shows no scrollbar. */
110
- .step > .pages { margin-top: 0; scrollbar-width: none; } /* the scrolling box starts at the step's top edge (Lh 2026-09-23) */
110
+ .step > .pages { margin: 0; scrollbar-width: none; touch-action: pan-y; } /* pan-y: the walk scrolls itself up and down; a sideways swipe is the Walkthrough's (it turns the page) */ /* the scrolling box is the whole content: from the step's top edge, edge to edge — no margin (Lh 2026-09-23) */
111
+ .step > .pages > .page { padding-inline: var(--screen-pad); } /* the words keep the inset INSIDE it */
111
112
  .step > .pages > .page { margin-top: var(--_lead, 0rem); } /* the lead is each PAGE's margin (15% with no top, the screen's gap under a bar): the words start there and scroll up to the box's edge */
112
113
  .step > .pages::-webkit-scrollbar { display: none; }
113
114
  .step > .pages > .page {
114
- padding-bottom: calc(var(--screen-lead, 15cqh) + var(--bottom-h, 0rem)); /* + the bottom layer, measured by Screen: the last word scrolls clear of it */
115
+ padding-bottom: var(--bottom-h, 0rem); /* exactly the bottom layer (measured by Screen): a page scrolls ONLY when the layer would cover its words, and then just far enough to clear them — at 100% nothing scrolls (Lh 2026-09-23) */
115
116
  }
116
117
 
117
118
  /* ============================================================
@@ -143,3 +144,17 @@
143
144
  them — timebank #137); the words keep the screen's gap as their lead (--_lead, .appbar + .step above) */
144
145
  .screen--fill > .appbar:has(+ .step) { margin-bottom: calc(-1 * var(--screen-gap)); }
145
146
  .screen--fill > .appbar + .sheet-stage { margin-top: 0; }
147
+ /* a walk page that OPENS on a picture (Lh 2026-09-23): the picture takes the lead's room — as wide as the content (edge to edge), from the top edge, as tall as
148
+ the lead + the picture's own height (--base-content-media-h), square — so the words start where they always did */
149
+ .step > .pages > .page:has(> :is(img, .ph):first-child) { margin-top: 0; }
150
+ .step > .pages > .page > :is(img, .ph):first-child {
151
+ width: calc(100% + 2 * var(--screen-pad)); /* the content's width: the page's inset given back */
152
+ max-width: none;
153
+ margin-inline: calc(-1 * var(--screen-pad));
154
+ height: calc(var(--_lead, 0rem) + var(--base-content-media-h, 14rem));
155
+ max-height: none;
156
+ border-radius: 0;
157
+ border-inline: 0;
158
+ border-top: 0;
159
+ object-fit: cover;
160
+ }
package/dist/cardds.css CHANGED
@@ -768,21 +768,21 @@ button { font: inherit; cursor: pointer; }
768
768
  <section class="page" inert>…</section>
769
769
  </div></section> <div class="action-bar action-bar--pager">← →</div> */
770
770
  ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
771
- ::view-transition-old(centre-card), ::view-transition-new(centre-card) {
771
+ ::view-transition-old(centre-card-out), ::view-transition-new(centre-card-in) {
772
772
  animation-duration: var(--motion-slide);
773
773
  animation-timing-function: ease-out;
774
774
  mix-blend-mode: normal;
775
775
  }
776
- :root[data-slide="next"]::view-transition-old(centre-card) { animation-name: centre-card-out-left; }
777
- :root[data-slide="next"]::view-transition-new(centre-card) { animation-name: centre-card-in-right; }
778
- :root[data-slide="back"]::view-transition-old(centre-card) { animation-name: centre-card-out-right; }
779
- :root[data-slide="back"]::view-transition-new(centre-card) { animation-name: centre-card-in-left; }
776
+ :root[data-slide="next"]::view-transition-old(centre-card-out) { animation-name: centre-card-out-left; }
777
+ :root[data-slide="next"]::view-transition-new(centre-card-in) { animation-name: centre-card-in-right; }
778
+ :root[data-slide="back"]::view-transition-old(centre-card-out) { animation-name: centre-card-out-right; }
779
+ :root[data-slide="back"]::view-transition-new(centre-card-in) { animation-name: centre-card-in-left; }
780
780
  @keyframes centre-card-out-left { to { translate: -100% 0; opacity: 0; } }
781
781
  @keyframes centre-card-out-right { to { translate: 100% 0; opacity: 0; } }
782
782
  @keyframes centre-card-in-right { from { translate: 100% 0; opacity: 0; } }
783
783
  @keyframes centre-card-in-left { from { translate: -100% 0; opacity: 0; } }
784
784
  @media (prefers-reduced-motion: reduce) {
785
- ::view-transition-old(centre-card), ::view-transition-new(centre-card) { animation: none; }
785
+ ::view-transition-old(centre-card-out), ::view-transition-new(centre-card-in) { animation: none; }
786
786
  }
787
787
  /* the page dots under the card sit centred under it — the indicator belongs
788
788
  with the page that changes, never in the bar (Lh, 2026-09-12) */
@@ -1633,11 +1633,12 @@ button { font: inherit; cursor: pointer; }
1633
1633
  (the picture is never pinned). Each page starts at the screen's lead and ends with the same room under it — the lead as
1634
1634
  padding above AND below — so the words begin near the middle and, when a big text size runs them long, scroll up clear.
1635
1635
  The walk shows no scrollbar. */
1636
- .step > .pages { margin-top: 0; scrollbar-width: none; } /* the scrolling box starts at the step's top edge (Lh 2026-09-23) */
1636
+ .step > .pages { margin: 0; scrollbar-width: none; touch-action: pan-y; } /* pan-y: the walk scrolls itself up and down; a sideways swipe is the Walkthrough's (it turns the page) */ /* the scrolling box is the whole content: from the step's top edge, edge to edge — no margin (Lh 2026-09-23) */
1637
+ .step > .pages > .page { padding-inline: var(--screen-pad); } /* the words keep the inset INSIDE it */
1637
1638
  .step > .pages > .page { margin-top: var(--_lead, 0rem); } /* the lead is each PAGE's margin (15% with no top, the screen's gap under a bar): the words start there and scroll up to the box's edge */
1638
1639
  .step > .pages::-webkit-scrollbar { display: none; }
1639
1640
  .step > .pages > .page {
1640
- padding-bottom: calc(var(--screen-lead, 15cqh) + var(--bottom-h, 0rem)); /* + the bottom layer, measured by Screen: the last word scrolls clear of it */
1641
+ padding-bottom: var(--bottom-h, 0rem); /* exactly the bottom layer (measured by Screen): a page scrolls ONLY when the layer would cover its words, and then just far enough to clear them — at 100% nothing scrolls (Lh 2026-09-23) */
1641
1642
  }
1642
1643
 
1643
1644
  /* ============================================================
@@ -1669,6 +1670,20 @@ button { font: inherit; cursor: pointer; }
1669
1670
  them — timebank #137); the words keep the screen's gap as their lead (--_lead, .appbar + .step above) */
1670
1671
  .screen--fill > .appbar:has(+ .step) { margin-bottom: calc(-1 * var(--screen-gap)); }
1671
1672
  .screen--fill > .appbar + .sheet-stage { margin-top: 0; }
1673
+ /* a walk page that OPENS on a picture (Lh 2026-09-23): the picture takes the lead's room — as wide as the content (edge to edge), from the top edge, as tall as
1674
+ the lead + the picture's own height (--base-content-media-h), square — so the words start where they always did */
1675
+ .step > .pages > .page:has(> :is(img, .ph):first-child) { margin-top: 0; }
1676
+ .step > .pages > .page > :is(img, .ph):first-child {
1677
+ width: calc(100% + 2 * var(--screen-pad)); /* the content's width: the page's inset given back */
1678
+ max-width: none;
1679
+ margin-inline: calc(-1 * var(--screen-pad));
1680
+ height: calc(var(--_lead, 0rem) + var(--base-content-media-h, 14rem));
1681
+ max-height: none;
1682
+ border-radius: 0;
1683
+ border-inline: 0;
1684
+ border-top: 0;
1685
+ object-fit: cover;
1686
+ }
1672
1687
 
1673
1688
  /* ---- css/forms.css — outlined fields, add-row ---- */
1674
1689
  /* ============================================================
@@ -1747,15 +1762,21 @@ button { font: inherit; cursor: pointer; }
1747
1762
  ============================================================ */
1748
1763
 
1749
1764
  /* ---- route: endpoint · dotted connector (icon in middle) · endpoint ---- */
1750
- .route { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; column-gap: var(--route-col-gap, var(--sp-1)); } /* line → icon: --sp-1, as the via always had; code → line is the code row's own --sp-3 */
1765
+ .route { display: grid; grid-template-columns: 1fr auto 1fr; grid-template-rows: auto auto auto; align-items: end; column-gap: var(--route-col-gap, var(--sp-1)); row-gap: var(--route-point-gap, var(--sp-0)); }
1766
+ /* ONE grid for both ends (Lh 2026-09-23): place · code · time are rows shared by the two ends, so the codes — and the dotted runs
1767
+ on them — sit on one line even when a long place name wraps on one side; the plane sits on that code row */ /* line → icon: --sp-1, as the via always had; code → line is the code row's own --sp-3 */
1751
1768
  /* the two endpoints get EQUAL columns whatever their words measure, so the via
1752
1769
  (the icon alone) sits at the card's centre; each dotted run lives on ITS
1753
1770
  endpoint's code row and fills that side's leftover — with short names the
1754
1771
  runs are long and equal, a long place name wraps in its half instead of
1755
1772
  pushing the plane (Lh 2026-09-16) */
1756
1773
 
1757
- .route__point { display: grid; gap: var(--route-point-gap, var(--sp-0)); min-width: 0; }
1758
- .route__point--end { text-align: right; }
1774
+ .route__point { display: contents; }
1775
+ .route__point > * { grid-column: 1; min-width: 0; }
1776
+ .route__point--end > * { grid-column: 3; text-align: right; }
1777
+ .route__place { grid-row: 1; }
1778
+ .route__code { grid-row: 2; align-self: center; }
1779
+ .route__time { grid-row: 3; align-self: start; }
1759
1780
 
1760
1781
  .route__code { font: var(--route-code-font, var(--type-h2)); letter-spacing: var(--track-h2); display: flex; align-items: center; gap: var(--route-code-gap, var(--sp-3)); }
1761
1782
  .route__code::after, .route__point--end .route__code::before { content: ""; flex: 1; border-top: var(--rule-w) dotted currentColor; opacity: 0.65; }
@@ -1764,7 +1785,7 @@ button { font: inherit; cursor: pointer; }
1764
1785
  .route__place { font: var(--route-place-font, var(--type-caption)); color: var(--route-place-ink, var(--card-muted)); }
1765
1786
  .card--3 .route__place { color: inherit; opacity: 0.7; }
1766
1787
 
1767
- .route__via { display: flex; align-items: center; opacity: 0.65; font-size: var(--route-via-font-size, var(--fs-body)); }
1788
+ .route__via { grid-column: 2; grid-row: 2; align-self: center; display: flex; align-items: center; opacity: 0.65; font-size: var(--route-via-font-size, var(--fs-body)); }
1768
1789
 
1769
1790
  /* ---- tile badge: tiny label over a big value (gate, seat, zone) ---- */
1770
1791
  .tile-badge {
@@ -2901,7 +2922,7 @@ button { font: inherit; cursor: pointer; }
2901
2922
  justify-content: start;
2902
2923
  align-items: center;
2903
2924
  gap: var(--sp-1) var(--sp-3);
2904
- max-width: 88%;
2925
+ max-width: var(--bubble-max-w, 75%); /* a message may run to three quarters of the thread (Lh 2026-09-23) */
2905
2926
  margin-right: auto; /* theirs: hugs the left */
2906
2927
  }
2907
2928
  .bubble--right { grid-template-columns: auto minmax(0, max-content); justify-content: end; margin-right: 0; margin-left: auto; } /* mine: hugs the right, the actions on its left */
@@ -3135,14 +3156,25 @@ button { font: inherit; cursor: pointer; }
3135
3156
  /* ---- wave: audio waveform stand-in for voice rows ----
3136
3157
  <span class="wave"></span> — sits in a .row__body, fills the width */
3137
3158
  .wave {
3138
- display: block;
3159
+ /* only WHOLE bars show (Lh 2026-09-23 — a clipped last bar read thinner): the columns are as many whole bar-widths as fit
3160
+ (auto-fill), spread edge to edge; bars that do not fit go to rows of no height, clipped away */
3161
+ display: grid;
3162
+ grid-template-columns: repeat(auto-fit, var(--wave-bar-width, 0.125rem)); /* auto-FIT: columns no bar fills collapse, so a wide row still spreads its bars edge to edge */
3163
+ grid-template-rows: 100%;
3164
+ grid-auto-rows: 0;
3165
+ justify-content: space-between;
3166
+ align-items: center;
3167
+ column-gap: var(--wave-gap, 0.125rem);
3139
3168
  height: var(--wave-h, var(--icon));
3140
3169
  width: 100%;
3141
- background: repeating-linear-gradient(90deg, currentColor 0 var(--sp-1), transparent var(--sp-1) var(--sp-2));
3142
- /* masks only read alpha: currentColor = "opaque" without naming a color */
3143
- mask: radial-gradient(ellipse 60% 100% at 50% 50%, currentColor 55%, transparent 58%) 0 0 / var(--tap-lg) 100% repeat-x;
3144
- -webkit-mask: radial-gradient(ellipse 60% 100% at 50% 50%, currentColor 55%, transparent 58%) 0 0 / var(--tap-lg) 100% repeat-x;
3145
- opacity: 0.8;
3170
+ overflow: hidden;
3171
+ }
3172
+ /* a bar: as tall as its level (--wave-at, 0–1 — DATA, set by Wave), rounded ends, the ink of whatever it sits on (Lh 2026-09-23) */
3173
+ .wave > i {
3174
+ width: 100%; /* its column: every bar one width, only the height tells the level (Lh 2026-09-23) */
3175
+ height: calc(var(--wave-at, 0.5) * 100%);
3176
+ border-radius: var(--r-chip);
3177
+ background: currentColor;
3146
3178
  }
3147
3179
 
3148
3180
  /* ---- mosaic: one big photo + two small, inside a card ----
package/dist/index.d.ts CHANGED
@@ -75,3 +75,4 @@ export * from './journey/NoteRow.js';
75
75
  export * from './templates/Walkthrough.js';
76
76
  export * from './templates/Home.js';
77
77
  export * from './sheets/StackSheet.js';
78
+ export * from './templates/CallSheet.js';
package/dist/index.js CHANGED
@@ -78,3 +78,4 @@ export * from './journey/NoteRow.js';
78
78
  export * from './templates/Walkthrough.js';
79
79
  export * from './templates/Home.js';
80
80
  export * from './sheets/StackSheet.js';
81
+ export * from './templates/CallSheet.js';
@@ -6,6 +6,8 @@ export interface MosaicProps extends ComponentProps<'div'> {
6
6
  export declare function Mosaic({ label, className, ...rest }: MosaicProps): import("react").JSX.Element;
7
7
  export interface WaveProps extends ComponentProps<'span'> {
8
8
  label?: string;
9
+ /** the bars' heights, 0–1, left to right — a real recording's levels; default: a mock-up shape */
10
+ levels?: number[];
9
11
  }
10
- /** Wave — the waveform stand-in for a voice note, filling a Row's body. */
11
- export declare function Wave({ label, className, ...rest }: WaveProps): import("react").JSX.Element;
12
+ /** Wave — a voice note's waveform: rounded bars, short and tall, filling a Row's body. `levels` draws a real one; without it, a mock-up. */
13
+ export declare function Wave({ label, levels, className, ...rest }: WaveProps): import("react").JSX.Element;
@@ -4,7 +4,10 @@ import { cx } from '../cx.js';
4
4
  export function Mosaic({ label = 'Photos', className, ...rest }) {
5
5
  return _jsx("div", { className: cx('mosaic', className), role: "img", "aria-label": label, ...rest });
6
6
  }
7
- /** Wave — the waveform stand-in for a voice note, filling a Row's body. */
8
- export function Wave({ label = 'Voice note', className, ...rest }) {
9
- return _jsx("span", { className: cx('wave', className), role: "img", "aria-label": label, ...rest });
7
+ /* a MOCK voice shape (Lh 2026-09-23): short and tall rounded bars, loud in bursts, quiet between — not a real recording */
8
+ const MOCK = [0.25, 0.45, 0.3, 0.6, 0.4, 0.2, 0.5, 0.85, 0.35, 0.7, 1, 0.55, 0.3, 0.75, 0.9, 0.45, 0.25, 0.6, 0.8, 0.4,
9
+ 0.2, 0.5, 0.7, 0.35, 0.95, 0.6, 0.3, 0.45, 0.8, 0.5, 0.25, 0.65, 0.9, 0.4, 0.2, 0.55, 0.75, 0.35, 0.5, 0.3];
10
+ /** Wave — a voice note's waveform: rounded bars, short and tall, filling a Row's body. `levels` draws a real one; without it, a mock-up. */
11
+ export function Wave({ label = 'Voice note', levels = MOCK, className, ...rest }) {
12
+ return (_jsx("span", { className: cx('wave', className), role: "img", "aria-label": label, ...rest, children: levels.map((v, i) => _jsx("i", { style: { '--wave-at': Math.max(0.12, Math.min(1, v)) } }, i)) }));
10
13
  }
@@ -41,11 +41,14 @@ export function slideTo(direction, update, scope = document) {
41
41
  const moving = () => scope.querySelector('.pages > [aria-current], .screen__centre > .card');
42
42
  const name = (el, v) => { if (el)
43
43
  el.style.viewTransitionName = v; };
44
+ /* the leaving page and the arriving one are TWO names, never one: one name makes the browser morph the box from where the old
45
+ page was (scrolled up) to where the new one is (at its top) — a vertical glide. Two names = each only slides sideways, the new
46
+ one arriving as itself, unscrolled (Lh 2026-09-23). */
44
47
  const before = moving();
45
- name(before, 'centre-card');
48
+ name(before, 'centre-card-out');
46
49
  root.dataset.slide = direction;
47
50
  let after = null;
48
- const transition = document.startViewTransition(() => { flushSync(update); name(before, ''); after = moving(); name(after, 'centre-card'); });
51
+ const transition = document.startViewTransition(() => { flushSync(update); name(before, ''); after = moving(); name(after, 'centre-card-in'); });
49
52
  void transition.finished.finally(() => {
50
53
  delete root.dataset.slide;
51
54
  name(after, '');
@@ -0,0 +1,36 @@
1
+ import { type ComponentProps, type ReactNode } from 'react';
2
+ import { Screen } from '../scaffold/Screen.js';
3
+ export interface CallSheetSheet {
4
+ /** the sheet head's title */
5
+ title: ReactNode;
6
+ /** the sheet body — rendered only while the sheet is up; a function is called then */
7
+ body: ReactNode | (() => ReactNode);
8
+ /** the sheet foot: the one button that finishes the sheet */
9
+ foot?: ReactNode;
10
+ }
11
+ export interface CallSheetProps extends Omit<ComponentProps<typeof Screen>, 'children' | 'fill' | 'top' | 'bottom' | 'title'> {
12
+ /** a picture above the words (optional) */
13
+ media?: ReactNode;
14
+ /** the page's one heading — its h1 (there is no top) */
15
+ heading: ReactNode;
16
+ /** the words: a string is one paragraph */
17
+ body?: ReactNode;
18
+ /** the button that calls the sheet */
19
+ action: ReactNode;
20
+ /** what comes up */
21
+ sheet: CallSheetSheet;
22
+ /** CONTROLLED: the sheet is up — with `onOpenChange`; or `defaultOpen` */
23
+ open?: boolean;
24
+ defaultOpen?: boolean;
25
+ onOpenChange?: (open: boolean) => void;
26
+ /** the sheet's resting states once up (default "peek full") */
27
+ states?: string;
28
+ /** layers over the screen */
29
+ children?: ReactNode;
30
+ }
31
+ /**
32
+ * CallSheet — TEMPLATE, a `Screen` with its parts filled — no top · content: a `Step` holding ONE `Page` (picture · heading (the h1) ·
33
+ * words · the button) and the `Sheet` that button calls · no bottom (the sheet finishes on its own foot). The page starts at the
34
+ * screen's lead (15%). The sheet waits `away`; the button brings it up full; its handle takes it down to peek.
35
+ */
36
+ export declare function CallSheet({ media, heading, body, action, sheet, open: openProp, defaultOpen, onOpenChange, states, children, ...rest }: CallSheetProps): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Screen } from '../scaffold/Screen.js';
4
+ import { Step } from '../sheets/Step.js';
5
+ import { Page, CtaPack } from '../scaffold/Page.js';
6
+ import { Sheet } from '../sheets/Sheet.js';
7
+ import { SheetBody } from '../sheets/SheetBody.js';
8
+ import { CardHead } from '../cards/CardHead.js';
9
+ import { CardFoot } from '../cards/CardFoot.js';
10
+ import { Text } from '../type/Text.js';
11
+ import { Btn } from '../actions/Btn.js';
12
+ /**
13
+ * CallSheet — TEMPLATE, a `Screen` with its parts filled — no top · content: a `Step` holding ONE `Page` (picture · heading (the h1) ·
14
+ * words · the button) and the `Sheet` that button calls · no bottom (the sheet finishes on its own foot). The page starts at the
15
+ * screen's lead (15%). The sheet waits `away`; the button brings it up full; its handle takes it down to peek.
16
+ */
17
+ export function CallSheet({ media, heading, body, action, sheet, open: openProp, defaultOpen = false, onOpenChange, states = 'peek full', children, ...rest }) {
18
+ const [own, setOwn] = useState(defaultOpen);
19
+ const open = openProp ?? own;
20
+ const [at, setAt] = useState('full');
21
+ const set = (next) => { if (openProp === undefined)
22
+ setOwn(next); onOpenChange?.(next); };
23
+ return (_jsxs(Screen, { fill: true, ...rest, children: [_jsxs(Step, { children: [_jsxs(Page, { children: [media, _jsx(CardHead, { level: "h1", heading: heading }), typeof body === 'string' ? _jsx(Text, { level: "body", children: body }) : body, _jsx(CtaPack, { children: _jsx(Btn, { primary: true, "aria-expanded": open, onClick: () => { setAt('full'); set(true); }, children: action }) })] }), _jsxs(Sheet, { state: open ? at : 'away', onStateChange: (s) => { if (s === 'away')
24
+ set(false);
25
+ else
26
+ setAt(s); }, states: states, children: [_jsx(CardHead, { heading: sheet.title }), _jsx(SheetBody, { children: open ? (typeof sheet.body === 'function' ? sheet.body() : sheet.body) : null }), sheet.foot != null && _jsx(CardFoot, { children: sheet.foot })] })] }), children] }));
27
+ }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from 'react';
2
+ import { useLayoutEffect, useRef, useState } from 'react';
3
3
  import { Screen } from '../scaffold/Screen.js';
4
4
  import { Step } from '../sheets/Step.js';
5
5
  import { Page } from '../scaffold/Page.js';
@@ -19,8 +19,9 @@ export function Walkthrough({ pages, at: atProp, defaultAt = 0, onAtChange, cta,
19
19
  const [own, setOwn] = useState(defaultAt);
20
20
  const at = atProp ?? own;
21
21
  const step = useRef(null);
22
- // a new page starts where a page starts — at its lead, not where the last one was scrolled to (Lh 2026-09-23)
23
- useEffect(() => { const p = step.current?.querySelector(':scope > .pages'); if (p)
22
+ // a new page starts where a page starts — at its lead, not where the last one was scrolled to (Lh 2026-09-23). A LAYOUT effect:
23
+ // it runs inside the page turn's commit, before the view transition takes its picture — only the sideways slide shows, no scroll
24
+ useLayoutEffect(() => { const p = step.current?.querySelector(':scope > .pages'); if (p)
24
25
  p.scrollTop = 0; }, [at]);
25
26
  const count = pages.length;
26
27
  const go = (to) => {
@@ -29,10 +30,27 @@ export function Walkthrough({ pages, at: atProp, defaultAt = 0, onAtChange, cta,
29
30
  slideTo(to > at ? 'next' : 'back', () => { if (atProp === undefined)
30
31
  setOwn(to); onAtChange?.(to); }, step.current ?? document);
31
32
  };
33
+ /* a sideways SWIPE turns the page (Lh 2026-09-23): left = next, right = back. The walk still scrolls up and down itself
34
+ (touch-action: pan-y); a swipe must be mostly sideways and long enough, so a scroll never turns a page */
35
+ const start = useRef(null);
36
+ const swipe = {
37
+ onPointerDown: (e) => { start.current = { x: e.clientX, y: e.clientY }; },
38
+ onPointerUp: (e) => {
39
+ const s0 = start.current;
40
+ start.current = null;
41
+ if (!s0)
42
+ return;
43
+ const dx = e.clientX - s0.x, dy = e.clientY - s0.y;
44
+ if (Math.abs(dx) < 48 || Math.abs(dx) < 1.5 * Math.abs(dy))
45
+ return;
46
+ go(dx < 0 ? at + 1 : at - 1);
47
+ },
48
+ onPointerCancel: () => { start.current = null; },
49
+ };
32
50
  const row = typeof cta === 'function' ? cta({ at, count, go }) : cta;
33
51
  const custom = row !== undefined && row !== null;
34
52
  const pager = (_jsxs(_Fragment, { children: [_jsx(IconBtn, { icon: "arrow-left", label: backLabel, disabled: at === 0, onClick: () => go(at - 1) }), _jsx(IconBtn, { invert: true, icon: "arrow-right", label: nextLabel, disabled: at === count - 1, onClick: () => go(at + 1) })] }));
35
53
  const dots = count > 1 ? _jsx(PagerAt, { at: at + 1, of: count }) : null;
36
54
  const bottom = dots || controls.length || ctaLink != null ? (_jsxs(ActionBar, { variant: "tiers", children: [dots, controls.map((c, i) => _jsx(ActionBarTier, { children: c }, i)), _jsx(ActionBarTier, { pager: true, children: custom ? row : pager }), ctaLink != null && _jsx(ActionBarTier, { className: "action-bar__tier--link", children: ctaLink })] })) : (_jsx(ActionBar, { variant: "pager", children: custom ? row : pager }));
37
- return (_jsxs(Screen, { fill: true, bottom: bottom, ...rest, children: [_jsx(Step, { ref: step, children: _jsx(Pages, { children: pages.map((p, i) => (_jsxs(Page, { "aria-current": i === at ? 'step' : undefined, inert: i !== at || undefined, children: [p.media, _jsx(CardHead, { level: "h1", heading: p.heading }), typeof p.body === 'string' ? _jsx(Text, { level: "body", children: p.body }) : p.body] }, i))) }) }), children] }));
55
+ return (_jsxs(Screen, { fill: true, bottom: bottom, ...rest, children: [_jsx(Step, { ref: step, children: _jsx(Pages, { ...swipe, children: pages.map((p, i) => (_jsxs(Page, { "aria-current": i === at ? 'step' : undefined, inert: i !== at || undefined, children: [p.media, _jsx(CardHead, { level: "h1", heading: p.heading }), typeof p.body === 'string' ? _jsx(Text, { level: "body", children: p.body }) : p.body] }, i))) }) }), children] }));
38
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "card-first mobile design system, React-first: the components in src/ are thin wrappers over the CSS contract (css/*.css stays the only truth); gallery/ shows every story live (npm run dev), tests/ measures the geometry.",