@usableapp/cardds 0.7.32 → 0.7.34

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,21 @@
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.7.34 — 2026-10-01
6
+
7
+ ### Changed
8
+ - **A secret `Pin`: each typed digit flashes, then is a dot** (Lh 2026-10-01): the digit is readable for half a second, and turns into a dot at once when the next digit lands. Before, the phone's browser decided: it shows the last typed character of a masked field for a second or more, per field — and a Pin is six fields, so several digits of a PIN stood readable together (Android Chrome). The Pin now draws the dot and times the flash itself; `-webkit-text-security` is no longer used. A pasted code does not flash. Nothing to change in a screen.
9
+ ### Added
10
+ - Knobs `--pin-flash-time` (how long a typed digit stays readable, `0.5s`; `0s` = never shown) and `--pin-dot` (the dot's size, `0.24em`).
11
+
12
+ ## 0.7.33 — 2026-10-01
13
+
14
+ ### Changed
15
+ - **A StackSheet tab's count grows with the text** (Lh 2026-10-01): the red circle was a fixed 20px while its number followed `--fs-base` — at a large text size the number ran over it. The circle is now cut from its own number's size (20px at the default, 32px at 160%); a long count ("10", "99+") makes it a capsule. The number sits on the circle's middle (it sat low), and the circle stands 8px out of the tab's corner instead of 4 — beside the icon, not on the disc's edge. Nothing to change in a screen.
16
+ - **A screen fills the phone, edge to edge** (Lh 2026-10-01): the column's cap `--screen-w` is `30rem` (480px), was `25rem` (400) — a phone 412–448px wide showed a strip of bare ground each side of the screen, its bars and its sheets. No phone reaches the cap now; it only keeps a tablet / desktop window to a phone-sized column. Nothing to change in a screen; a project that set `--screen-w` itself keeps its value.
17
+ ### Added
18
+ - Knobs `--stack-sheet-count-pad-x` (the room beside the number), `--stack-sheet-count-lift` (the room under it — retune for another font), `--stack-sheet-count-out` (how far it stands out of the corner). `--stack-sheet-count-h` as before; its default is `1.175em`, was `1.25rem`.
19
+
5
20
  ## 0.7.32 — 2026-09-29
6
21
 
7
22
  ### Changed
package/README.md CHANGED
@@ -650,7 +650,7 @@ Every length token resolves to rem — a multiplier × one of four rem bases
650
650
  (`--sp-4` = `--sp-base` × 4 = 1rem, `--tap` = `--tap-base` = 3rem, `--r-card` =
651
651
  `--r-base` × 2, `--fs-h1` = `--fs-base` × 1.2; see "A project's theme") — so the
652
652
  whole system scales with the root font size. `base.css` keeps it at `100%` (the browser's setting, 16px by
653
- default) and steps narrow phones (≤360px) to `93.75%`, which keeps the 25rem
653
+ default) and steps narrow phones (≤360px) to `93.75%`, which keeps the
654
654
  column, its 48px taps and its type in proportion on 320–360 screens. Only
655
655
  `--border-w`, `--rule-w` and the focus ring stay in px, so edges stay crisp.
656
656
  An app with its own text-size setting calls `setTextScale(percent)`, which writes
package/css/choice.css CHANGED
@@ -198,7 +198,22 @@
198
198
  is hidden — its height and place are the browser's; the pill's bar is one rule). A filled cell selects its digit instead. */
199
199
  }
200
200
  .pin__cell:not(:placeholder-shown) { color: var(--pin-cell-filled-ink, var(--accent)); } /* a typed digit: coloured words on the cell's own ground — no fill (Lh 2026-09-24; was an accent-filled pill) */
201
- .pin--secret .pin__cell { -webkit-text-security: disc; text-security: disc; } /* a PIN: a dot per digit, the number pad kept (Lh 2026-09-25) */
201
+ /* a PIN (secret): the cell DRAWS the dot, the character itself is not painted — except for a moment, when it has just been typed
202
+ (Lh 2026-10-01: "each digit must flash, then go at once"). It was `-webkit-text-security: disc`: a phone's browser then shows
203
+ the last typed character of a masked field for a second or more ("password echo", Android) — per FIELD, and a Pin is six
204
+ fields, so several digits stood readable together. Now the timing is the Pin's own: `.is-fresh` (set by Pin on the cell just
205
+ typed, taken off when the next digit lands) plays `pin-flash`; after it, and in every other cell, the fill is transparent and
206
+ the dot is the cell's background, in currentColor — a wrong code's red reaches it. */
207
+ .pin--secret .pin__cell:not(:placeholder-shown) {
208
+ -webkit-text-fill-color: transparent;
209
+ background-image: radial-gradient(circle closest-side, currentColor 96%, transparent);
210
+ background-size: var(--pin-dot, 0.24em) var(--pin-dot, 0.24em);
211
+ background-position: center;
212
+ background-repeat: no-repeat;
213
+ }
214
+ .pin--secret .pin__cell.is-fresh:not(:placeholder-shown) { animation: pin-flash var(--pin-flash-time, 0.5s) steps(1); }
215
+ @keyframes pin-flash { 0% { -webkit-text-fill-color: currentColor; background-size: 0 0; } }
216
+ .pin--secret .pin__cell::selection { background: transparent; } /* its digit is selected when the cell takes the cursor: nothing to show of it */
202
217
  .pin__cell:focus { outline: none; border-color: var(--pin-cell-active-border, currentColor); }
203
218
  .pin__cell:focus:placeholder-shown {
204
219
  background-image: linear-gradient(currentColor, currentColor);
package/css/stack.css CHANGED
@@ -292,11 +292,14 @@
292
292
  .stack-sheet__tab { position: relative; }
293
293
  .stack-sheet__tab > .stack-sheet__count { /* outranks .badge (numbers.css loads after this file) */
294
294
  position: absolute;
295
- top: calc(-1 * var(--sp-1));
296
- right: calc(-1 * var(--sp-1));
297
- min-width: var(--stack-sheet-count-h, 1.25rem);
298
- height: var(--stack-sheet-count-h, 1.25rem);
299
- padding: 0 var(--sp-1);
295
+ top: calc(-1 * var(--stack-sheet-count-out, var(--sp-2))); /* how far it stands OUT of the tab's corner, up and to the right: at sp-1 it
296
+ sat exactly on the chosen tab's disc edge — it reads as a mark beside the icon, not part of the disc (Lh 2026-10-01) */
297
+ right: calc(-1 * var(--stack-sheet-count-out, var(--sp-2)));
298
+ min-width: var(--stack-sheet-count-h, 1.175em); /* em, not rem (Lh 2026-10-01): the circle is cut from ITS OWN number's size — at a
299
+ large text size the number grew and a 20px circle did not; 1.175em of a 17px caption = the 20px it always was */
300
+ height: var(--stack-sheet-count-h, 1.175em);
301
+ padding: 0 var(--stack-sheet-count-pad-x, 0.25em) var(--stack-sheet-count-lift, 0.1em); /* "99+" keeps its room at every text size;
302
+ the room UNDER the number lifts it onto the circle's middle: a digit has no descender, so a centred line box sets it low (Lh 2026-10-01) */
300
303
  font: var(--type-caption);
301
304
  line-height: 1;
302
305
  pointer-events: none;
package/css/tokens.css CHANGED
@@ -12,7 +12,9 @@
12
12
 
13
13
  :root {
14
14
  /* ---- layout ---- */
15
- --screen-w: 25rem; /* 400: mobile column — .screen, .action-bar, .sheet-stage */
15
+ --screen-w: 30rem; /* 480: the column's CAP — .screen, .action-bar, .sheet-stage. A phone never reaches it (the widest are ~450 CSS px):
16
+ on a phone the screen is the device, edge to edge; the cap only keeps a tablet / desktop window to a phone-sized
17
+ column. Was 25rem: a 412px phone showed a strip each side (Lh 2026-10-01) */
16
18
  --screen-centre: 40%; /* the human centre of a screen: the middle of its top four fifths — the bottom fifth is the thumb's (Lh, 2026-09-10). .card--centre sits here */
17
19
 
18
20
  /* ---- the four bases: what a project retunes (Lh, 2026-09-20) ----
package/dist/cardds.css CHANGED
@@ -53,7 +53,9 @@
53
53
 
54
54
  :root {
55
55
  /* ---- layout ---- */
56
- --screen-w: 25rem; /* 400: mobile column — .screen, .action-bar, .sheet-stage */
56
+ --screen-w: 30rem; /* 480: the column's CAP — .screen, .action-bar, .sheet-stage. A phone never reaches it (the widest are ~450 CSS px):
57
+ on a phone the screen is the device, edge to edge; the cap only keeps a tablet / desktop window to a phone-sized
58
+ column. Was 25rem: a 412px phone showed a strip each side (Lh 2026-10-01) */
57
59
  --screen-centre: 40%; /* the human centre of a screen: the middle of its top four fifths — the bottom fifth is the thumb's (Lh, 2026-09-10). .card--centre sits here */
58
60
 
59
61
  /* ---- the four bases: what a project retunes (Lh, 2026-09-20) ----
@@ -1249,11 +1251,14 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1249
1251
  .stack-sheet__tab { position: relative; }
1250
1252
  .stack-sheet__tab > .stack-sheet__count { /* outranks .badge (numbers.css loads after this file) */
1251
1253
  position: absolute;
1252
- top: calc(-1 * var(--sp-1));
1253
- right: calc(-1 * var(--sp-1));
1254
- min-width: var(--stack-sheet-count-h, 1.25rem);
1255
- height: var(--stack-sheet-count-h, 1.25rem);
1256
- padding: 0 var(--sp-1);
1254
+ top: calc(-1 * var(--stack-sheet-count-out, var(--sp-2))); /* how far it stands OUT of the tab's corner, up and to the right: at sp-1 it
1255
+ sat exactly on the chosen tab's disc edge — it reads as a mark beside the icon, not part of the disc (Lh 2026-10-01) */
1256
+ right: calc(-1 * var(--stack-sheet-count-out, var(--sp-2)));
1257
+ min-width: var(--stack-sheet-count-h, 1.175em); /* em, not rem (Lh 2026-10-01): the circle is cut from ITS OWN number's size — at a
1258
+ large text size the number grew and a 20px circle did not; 1.175em of a 17px caption = the 20px it always was */
1259
+ height: var(--stack-sheet-count-h, 1.175em);
1260
+ padding: 0 var(--stack-sheet-count-pad-x, 0.25em) var(--stack-sheet-count-lift, 0.1em); /* "99+" keeps its room at every text size;
1261
+ the room UNDER the number lifts it onto the circle's middle: a digit has no descender, so a centred line box sets it low (Lh 2026-10-01) */
1257
1262
  font: var(--type-caption);
1258
1263
  line-height: 1;
1259
1264
  pointer-events: none;
@@ -2961,7 +2966,22 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2961
2966
  is hidden — its height and place are the browser's; the pill's bar is one rule). A filled cell selects its digit instead. */
2962
2967
  }
2963
2968
  .pin__cell:not(:placeholder-shown) { color: var(--pin-cell-filled-ink, var(--accent)); } /* a typed digit: coloured words on the cell's own ground — no fill (Lh 2026-09-24; was an accent-filled pill) */
2964
- .pin--secret .pin__cell { -webkit-text-security: disc; text-security: disc; } /* a PIN: a dot per digit, the number pad kept (Lh 2026-09-25) */
2969
+ /* a PIN (secret): the cell DRAWS the dot, the character itself is not painted — except for a moment, when it has just been typed
2970
+ (Lh 2026-10-01: "each digit must flash, then go at once"). It was `-webkit-text-security: disc`: a phone's browser then shows
2971
+ the last typed character of a masked field for a second or more ("password echo", Android) — per FIELD, and a Pin is six
2972
+ fields, so several digits stood readable together. Now the timing is the Pin's own: `.is-fresh` (set by Pin on the cell just
2973
+ typed, taken off when the next digit lands) plays `pin-flash`; after it, and in every other cell, the fill is transparent and
2974
+ the dot is the cell's background, in currentColor — a wrong code's red reaches it. */
2975
+ .pin--secret .pin__cell:not(:placeholder-shown) {
2976
+ -webkit-text-fill-color: transparent;
2977
+ background-image: radial-gradient(circle closest-side, currentColor 96%, transparent);
2978
+ background-size: var(--pin-dot, 0.24em) var(--pin-dot, 0.24em);
2979
+ background-position: center;
2980
+ background-repeat: no-repeat;
2981
+ }
2982
+ .pin--secret .pin__cell.is-fresh:not(:placeholder-shown) { animation: pin-flash var(--pin-flash-time, 0.5s) steps(1); }
2983
+ @keyframes pin-flash { 0% { -webkit-text-fill-color: currentColor; background-size: 0 0; } }
2984
+ .pin--secret .pin__cell::selection { background: transparent; } /* its digit is selected when the cell takes the cursor: nothing to show of it */
2965
2985
  .pin__cell:focus { outline: none; border-color: var(--pin-cell-active-border, currentColor); }
2966
2986
  .pin__cell:focus:placeholder-shown {
2967
2987
  background-image: linear-gradient(currentColor, currentColor);
@@ -22,8 +22,10 @@ export interface PinProps extends Omit<ComponentProps<'div'>, 'onChange' | 'defa
22
22
  match?: string;
23
23
  /** the words when a check fails: the code does not match, or the step's button was pressed with cells still empty */
24
24
  formatError?: ReactNode;
25
- /** a SECRET code (a PIN, not an OTP): each typed digit shows as a dot — `-webkit-text-security`, so the keyboard stays the
26
- * number pad (a password field brings a full keyboard on some phones); no SMS autofill, no password manager (Lh 2026-09-25) */
25
+ /** a SECRET code (a PIN, not an OTP): each typed digit FLASHES — readable for a moment (`--pin-flash-time`), then a dot, and a
26
+ * dot at once when the next digit lands (Lh 2026-10-01). The cell draws the dot itself: the field stays a text field, so the
27
+ * keyboard stays the number pad (a password field brings a full keyboard on some phones) and the browser's own reveal of a
28
+ * masked field — a second per cell, six cells readable together — has no part in it; no SMS autofill, no password manager */
27
29
  secret?: boolean;
28
30
  /** focus the first empty cell on mount */
29
31
  autoFocus?: boolean;
package/dist/forms/Pin.js CHANGED
@@ -73,6 +73,7 @@ export function Pin({ length = 6, value, defaultValue = '', label = word('digit'
73
73
  const [cleared, setCleared] = useState(undefined); // the app's error whose miss has played out (its cells emptied)
74
74
  const wrong = (errOn && error !== cleared) || bad;
75
75
  const restart = useRef(false);
76
+ const [fresh, setFresh] = useState(null); // the cell whose digit was just typed: a secret Pin shows it for a moment
76
77
  useEffect(() => {
77
78
  if (!errOn) {
78
79
  setCleared(undefined);
@@ -103,15 +104,18 @@ export function Pin({ length = 6, value, defaultValue = '', label = word('digit'
103
104
  // the cell still held its red digit (not selected, so it stays visible): what came in is the value minus that digit — one
104
105
  // typed digit, or a whole pasted / SMS-autofilled code (then it fills every cell, never just the last digit: timebank 142)
105
106
  const fresh = digits.length >= length ? digits.slice(-length) : old && digits.length > 1 && digits.startsWith(old) ? digits.slice(old.length) : old && digits.length > 1 && digits.endsWith(old) ? digits.slice(0, -old.length) : digits;
107
+ setFresh(fresh.length === 1 ? 0 : null);
106
108
  set(fresh);
107
109
  cell(Math.min(fresh.length, length - 1))?.focus();
108
110
  return;
109
111
  }
110
112
  const at = Math.min(i, code.length); // a code has no gaps: a digit typed further along lands in the first empty cell
111
113
  if (!digits) {
114
+ setFresh(null);
112
115
  set(code.slice(0, at) + code.slice(at + 1));
113
116
  return;
114
117
  }
118
+ setFresh(digits.length === 1 ? at : null); // one typed digit flashes; a pasted run does not
115
119
  set(code.slice(0, at) + digits + code.slice(at + digits.length)); // a paste (or a typed run) spills over the cells that follow
116
120
  cell(Math.min(at + digits.length, length - 1))?.focus();
117
121
  };
@@ -124,12 +128,14 @@ export function Pin({ length = 6, value, defaultValue = '', label = word('digit'
124
128
  return;
125
129
  e.preventDefault();
126
130
  restart.current = false;
131
+ setFresh(null);
127
132
  set(digits);
128
133
  cell(Math.min(digits.length, length - 1))?.focus();
129
134
  };
130
135
  const key = (i) => (e) => {
131
136
  if (e.key === 'Backspace' && !e.currentTarget.value && i > 0) {
132
137
  e.preventDefault();
138
+ setFresh(null);
133
139
  set(code.slice(0, i - 1) + code.slice(i));
134
140
  cell(i - 1)?.focus();
135
141
  }
@@ -143,9 +149,10 @@ export function Pin({ length = 6, value, defaultValue = '', label = word('digit'
143
149
  }
144
150
  };
145
151
  return (_jsx("div", { ref: ref, className: cx('pin', secret && 'pin--secret', wrong && 'pin--error', shake && 'pin--shake', className), ...rest, onAnimationEnd: (e) => { if (e.animationName === 'pin-shake')
146
- setShake(false); onAnimationEnd?.(e); }, children: Array.from({ length }, (_, i) => (
152
+ setShake(false); if (e.animationName === 'pin-flash')
153
+ setFresh(null); onAnimationEnd?.(e); }, children: Array.from({ length }, (_, i) => (
147
154
  // no maxLength: the browser would cut a pasted code before this ever saw it. A cell holds one digit — typing into a full one
148
155
  // replaces it, so its digit is selected whenever the cell takes the focus
149
- _jsx("input", { className: "pin__cell", inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, autoComplete: i === 0 && !secret ? 'one-time-code' : 'off', onChange: input(i), onPaste: paste, onKeyDown: key(i), onFocus: (e) => { if (!restart.current)
156
+ _jsx("input", { className: cx('pin__cell', secret && fresh === i && 'is-fresh'), inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, autoComplete: i === 0 && !secret ? 'one-time-code' : 'off', onChange: input(i), onPaste: paste, onKeyDown: key(i), onFocus: (e) => { if (!restart.current)
150
157
  e.currentTarget.select(); } }, i))) }));
151
158
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.32",
3
+ "version": "0.7.34",
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.",