@usableapp/cardds 0.2.2 → 0.3.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/cardds.js CHANGED
@@ -1,337 +1,10 @@
1
- /* cardds.js — optional behaviour helpers (the CSS works without it).
2
- Dropdown: clicking a .menu__item marks it current, copies its label into the
3
- .dropdown__label of the enclosing .dropdown, closes the popover and fires
4
- "change" (detail.value) on the .dropdown. */
5
- document.addEventListener('click', e => {
6
- const item = e.target.closest('.menu__item');
7
- if (!item) return;
8
- const menu = item.closest('.menu');
9
- const dd = menu && menu.closest('.dropdown');
10
- if (!menu.classList.contains('menu--actions')) { /* an actions menu holds no value: nothing stays current */
11
- menu.querySelectorAll('.menu__item').forEach(i => i.removeAttribute('aria-current'));
12
- item.setAttribute('aria-current', 'true');
13
- }
14
- if (dd) {
15
- const label = dd.querySelector('.dropdown__label');
16
- const value = item.dataset.value ?? item.textContent.trim();
17
- if (label) label.textContent = item.dataset.label ?? item.textContent.trim();
18
- dd.dataset.value = value;
19
- dd.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value } }));
20
- }
21
- if (menu.matches(':popover-open')) menu.hidePopover();
22
- });
1
+ /* cardds.js — the little that is not (yet) a component's own code. Nothing here ties one component to another. */
23
2
 
24
- /* Pin (.pin, choice.css): one digit per cell. Typing a digit moves focus to
25
- the next cell; Backspace in an empty cell moves it back (and clears that
26
- one); a paste of the whole code fills the cells from the one it landed in.
27
- Only digits stay. Delegated, so a Pin rendered by any host — the React
28
- adapter, plain HTML — gets it; the cells stay ordinary inputs. */
29
- /* Postcard (.postcard, media.css): a tap on either face turns the card over.
30
- Delegated; the faces are buttons, so Enter and Space arrive as clicks. */
31
- document.addEventListener('click', e => {
32
- const face = e.target instanceof Element && e.target.closest('.postcard__face');
33
- if (!face) return;
34
- const card = face.closest('.postcard');
35
- if (!card) return;
36
- const flipped = card.classList.toggle('is-flipped');
37
- card.dispatchEvent(new CustomEvent('cardds:flip', { bubbles: true, detail: { flipped } }));
38
- });
39
-
40
- /* Slider (.slider, choice.css): the knob's place along the rail, published on
41
- the .slider as --slider-at (0..1) so the .slider__glyph can ride the knob.
42
- Delegated, and run once for every slider already on the page. */
43
- function carddsSliderAt(range) {
44
- const min = Number(range.min) || 0, max = Number(range.max) || 100;
45
- const at = max > min ? (Number(range.value) - min) / (max - min) : 0;
46
- range.closest('.slider')?.style.setProperty('--slider-at', String(Math.min(1, Math.max(0, at))));
47
- }
48
- document.addEventListener('input', e => {
49
- if (e.target instanceof HTMLInputElement && e.target.classList.contains('slider__range')) carddsSliderAt(e.target);
50
- });
51
- document.addEventListener('input', e => {
52
- const cell = e.target;
53
- if (!(cell instanceof HTMLInputElement) || !cell.classList.contains('pin__cell')) return;
54
- const cells = [...cell.closest('.pin').querySelectorAll('.pin__cell')];
55
- const i = cells.indexOf(cell);
56
- const digits = cell.value.replace(/\D/g, '');
57
- // a paste (or a typed run) spills over the cells that follow
58
- digits.split('').forEach((d, k) => { if (cells[i + k]) cells[i + k].value = d; });
59
- if (!digits) cell.value = '';
60
- else {
61
- const next = cells[Math.min(i + digits.length, cells.length - 1)];
62
- if (next !== cell) next.focus();
63
- else if (cell.value !== digits.slice(-1)) cell.value = digits.slice(-1);
64
- }
65
- carddsPinPublish(cells);
66
- });
67
- /* "cardds:pin" (bubbles, detail.code = the digits so far, detail.complete)
68
- after every change — the one thing a host listens to; the React adapter's
69
- Pin turns it into onChange */
70
- function carddsPinPublish(cells) {
71
- const code = cells.map(c => c.value).join('');
72
- cells[0].closest('.pin').dispatchEvent(new CustomEvent('cardds:pin', { bubbles: true, detail: { code, complete: code.length === cells.length } }));
73
- }
74
- /* a cell holds one digit; typing into a full one replaces it — select the
75
- digit before the insert lands (a paste of a whole code still spills over
76
- the cells that follow, above). No maxlength on the cells: the browser
77
- would truncate a pasted code before this ever saw it. */
78
- document.addEventListener('beforeinput', e => {
79
- const cell = e.target;
80
- if (!(cell instanceof HTMLInputElement) || !cell.classList.contains('pin__cell')) return;
81
- if (e.inputType === 'insertText' && cell.value && cell.selectionStart === cell.selectionEnd) cell.select();
82
- });
83
- document.addEventListener('focusin', e => {
84
- const cell = e.target;
85
- if (cell instanceof HTMLInputElement && cell.classList.contains('pin__cell') && cell.value) cell.select();
86
- });
87
- document.addEventListener('keydown', e => {
88
- const cell = e.target;
89
- if (!(cell instanceof HTMLInputElement) || !cell.classList.contains('pin__cell')) return;
90
- const cells = [...cell.closest('.pin').querySelectorAll('.pin__cell')];
91
- const i = cells.indexOf(cell);
92
- if (e.key === 'Backspace' && !cell.value && i > 0) {
93
- e.preventDefault();
94
- cells[i - 1].value = '';
95
- cells[i - 1].focus();
96
- carddsPinPublish(cells);
97
- } else if (e.key === 'ArrowLeft' && i > 0) { e.preventDefault(); cells[i - 1].focus(); }
98
- else if (e.key === 'ArrowRight' && i < cells.length - 1) { e.preventDefault(); cells[i + 1].focus(); }
99
- });
100
-
101
- /* Sheet stack (.sheet-stack--tap, stack.css): tap a peeked sheet to open it, its back
102
- button returns it. Sheets are rigid material — they move in, move out, or
103
- get covered, never morph — so the only animated property is translate;
104
- see the --tap block in stack.css for the whole model.
105
-
106
- This only ever toggles classes. Every card's resting slot, offset and
107
- paint order is declared in CSS, so a stack still renders correctly with
108
- this file absent — it just won't open. Works for any number of stacks on
109
- a page, with no ids or per-page wiring. */
110
- const CARDDS_STACK_PHASE = 380; /* ms before the before-pack un-tucks on
111
- close, so the after-pack is clear first */
112
-
113
- /* the stack's sheets, in slot order. Normally the stack's direct children;
114
- a host that wraps each child in one box of its own (a design tool's
115
- per-component frame, a router's element) is tolerated: the first .card
116
- inside each child then counts. Never deeper — a card inside a sheet's
117
- content is content, not a sheet. */
118
- function carddsStackCards(stack) {
119
- return [...stack.children]
120
- .filter(c => !c.classList.contains('sheet-stack__base')) // the base is what shows behind the sheets, never a sheet (a Card in it is content)
121
- .map(c => c.classList.contains('card') ? c : c.querySelector(':scope > .card')).filter(Boolean);
122
- }
123
-
124
- document.addEventListener('click', e => {
125
- const stack = e.target.closest('.sheet-stack--tap');
126
- if (!stack) return;
127
- const cards = carddsStackCards(stack);
128
- // the sheet this tap landed in: the member the target sits in, not the
129
- // nearest .card (a content card inside an open sheet must not count)
130
- const card = cards.find(c => c.contains(e.target));
131
- if (e.target.closest('.card__back')) { carddsCloseStack(stack, card); return; }
132
- if (card && !cards.some(c => c.classList.contains('is-open'))) {
133
- carddsOpenStack(stack, card);
134
- }
135
- });
136
-
137
- function carddsOpenStack(stack, card) {
138
- const cards = carddsStackCards(stack);
139
- const idx = cards.indexOf(card);
140
- cards.forEach((c, i) => {
141
- c.classList.remove('is-before', 'is-after');
142
- c.style.setProperty('--stagger', 0); // only is-before overrides this
143
- if (i < idx) {
144
- // already behind the tapped card — tuck under the next card's strip,
145
- // nearest one first, and let the opening card cover them
146
- c.style.setProperty('--stagger', idx - 1 - i);
147
- c.classList.add('is-before');
148
- } else if (i > idx) {
149
- c.classList.add('is-after'); // in front of it, so these must leave
150
- }
151
- });
152
- card.classList.add('is-open');
153
- }
154
-
155
- /* close: the sheet whose back button was tapped, read from the tap itself
156
- (a page with several stacks, or a re-render mid-animation, can leave a
157
- stale .is-open elsewhere); without one, the stack's open sheet */
158
- function carddsCloseStack(stack, card) {
159
- const cards = carddsStackCards(stack);
160
- card = card && cards.includes(card) ? card : cards.find(c => c.classList.contains('is-open'));
161
- if (!card) return;
162
- const idx = cards.indexOf(card);
163
- // phase 1: the after-pack moves back in from below while the opened card
164
- // drops into its own slot, passing down over the still-covered before-pack
165
- cards.filter((c, i) => i > idx).forEach(c => c.classList.remove('is-after'));
166
- card.classList.remove('is-open'); // back button starts its own crop-out now
167
- // phase 2: then the before-pack slides back up out from behind it, same
168
- // order it tucked away — nearest to the card first
169
- const before = cards.filter((c, i) => i < idx);
170
- setTimeout(() => before.forEach(c => c.classList.remove('is-before')), CARDDS_STACK_PHASE);
171
- }
172
-
173
- /* ---- sheet (.sheet, sheet.css): peek / half / full ----
174
- A sheet is rigid material, like a sheet in the stack (stack.css) — it never changes size, a
175
- state is only how far it slid — so this, like the stack code, only ever
176
- toggles classes and two custom properties. The CSS renders every state
177
- without it; this adds the handle's click and drag, the focus raise, the
178
- keyboard room, and one event. Delegated: any sheet on the page, no ids.
179
-
180
- - click / Enter / Space on .sheet__handle TOGGLES the sheet between its
181
- own state and peek (Lh 2026-09-16 — never a step-by-step climb). "Its
182
- own state" is the last non-peek state it rested in (authored, dragged
183
- to, or set), kept in data-sheet-home
184
- - data-sheet-states (default "peek half 3q full") lists the states a
185
- drag snaps to
186
- - drag on the handle writes the offset to --_drag (the CSS adds it to the
187
- state's translate) and on release snaps to the nearest state; a move
188
- shorter than --sp-2 is a tap. The snap is the one place the sheet's
189
- geometry is read, once per release: its height, and the two state
190
- tokens — registered in tokens.css so they read back resolved
191
- - focus inside a peeked sheet raises it to half (or full, if the sheet has
192
- no half) and reveals the control — the sheet's own scroll-padding
193
- already knows how much of it slid under the box's edge
194
- - the visual viewport's height is published on :root as --kb, the height
195
- the on-screen keyboard covers, so the bar (actions.css) and the step
196
- (step.css) can stay above it on iOS
197
- - "cardds:sheet" (bubbles, detail.state) after every state change
198
-
199
- carddsSheetSet(sheet, state) is the one entry point; an app that sets
200
- the state itself calls it rather than swapping classes, so listeners hear. */
201
- const CARDDS_SHEET_STATES = ['peek', 'half', '3q', 'full'];
202
- const CARDDS_SHEET_TAP_REM = 0.5; /* = --sp-2: a move shorter than this is a tap, not a drag */
203
-
204
- function carddsSheetStates(sheet) {
205
- const list = (sheet.dataset.sheetStates || '').split(/\s+/).filter(s => CARDDS_SHEET_STATES.includes(s));
206
- return list.length ? list : CARDDS_SHEET_STATES;
207
- }
208
-
209
- function carddsSheetState(sheet) {
210
- if (sheet.classList.contains('sheet--away')) return 'away'; // a called sheet not on the screen — a state to set, never one to snap or tap to
211
- return CARDDS_SHEET_STATES.find(s => sheet.classList.contains('sheet--' + s)) ?? 'half'; // the CSS default
212
- }
213
-
214
- function carddsSheetSet(sheet, state) {
215
- if (state !== 'away' && !CARDDS_SHEET_STATES.includes(state)) return;
216
- const was = carddsSheetState(sheet);
217
- [...CARDDS_SHEET_STATES, 'away'].forEach(s => sheet.classList.toggle('sheet--' + s, s === state));
218
- sheet.inert = state === 'away';
219
- /* where a tap from peek returns to: the non-peek state it is going to, or the
220
- one it is leaving (an authored state never passed through here before) */
221
- const rests = s => s !== 'peek' && s !== 'away';
222
- const home = rests(state) ? state : rests(was) ? was : null;
223
- if (home) sheet.dataset.sheetHome = home;
224
- if (was !== state) sheet.dispatchEvent(new CustomEvent('cardds:sheet', { bubbles: true, detail: { state } }));
225
- }
226
-
227
- /* the click that follows a drag on the same handle is not a tap — the drag
228
- already chose the state. Cleared on the next frame, so a drag that ends
229
- without a click (pointercancel) cannot eat a later, real one. */
230
- let carddsSheetSkipClick = null;
231
-
232
- document.addEventListener('click', e => {
233
- const handle = e.target.closest('.sheet__handle');
234
- if (!handle) return;
235
- if (handle === carddsSheetSkipClick) { carddsSheetSkipClick = null; return; }
236
- const sheet = handle.closest('.sheet');
237
- if (!sheet) return;
238
- carddsSheetSet(sheet, carddsSheetState(sheet) === 'peek' ? carddsSheetHome(sheet) : 'peek');
239
- });
240
-
241
- /* the state a tap from peek returns to: the last non-peek state it rested in,
242
- else the authored one, else the first non-peek state it snaps to */
243
- function carddsSheetHome(sheet) {
244
- const states = carddsSheetStates(sheet);
245
- const home = sheet.dataset.sheetHome;
246
- if (home && states.includes(home)) return home;
247
- const now = carddsSheetState(sheet);
248
- return now !== 'peek' ? now : states.find(s => s !== 'peek') ?? 'half';
249
- }
250
-
251
- /* how far the sheet has slid, in px: its rendered top against its resting top
252
- at full (the box's padding edge + its own offset). Read from geometry, not
253
- from the computed translate — that keeps percentages and calcs as text. */
254
- function carddsSheetY(sheet) {
255
- const box = sheet.offsetParent ?? sheet.parentElement;
256
- return sheet.getBoundingClientRect().top - (box.getBoundingClientRect().top + box.clientTop + sheet.offsetTop);
257
- }
258
-
259
- /* drag: where each state rests, in px from the sheet's top at full */
260
- function carddsSheetStops(sheet) {
261
- const cs = getComputedStyle(sheet);
262
- const height = sheet.getBoundingClientRect().height;
263
- return {
264
- full: 0,
265
- half: height * (parseFloat(cs.getPropertyValue('--sheet-half')) || 0), // a share of the height
266
- '3q': height * (parseFloat(cs.getPropertyValue('--sheet-3q')) || 0),
267
- peek: height - (parseFloat(cs.getPropertyValue('--sheet-peek')) || 0), // a length, resolved
268
- };
269
- }
270
-
271
- let carddsSheetDrag = null;
272
-
273
- document.addEventListener('pointerdown', e => {
274
- const handle = e.target.closest('.sheet__handle');
275
- if (!handle || !e.isPrimary || e.button !== 0) return;
276
- const sheet = handle.closest('.sheet');
277
- if (!sheet) return;
278
- const stops = carddsSheetStops(sheet);
279
- const states = carddsSheetStates(sheet);
280
- const y = carddsSheetY(sheet); // where it is now — mid-slide too, so a grab never jumps
281
- carddsSheetDrag = {
282
- sheet, handle, states, stops,
283
- startY: e.clientY,
284
- y0: y,
285
- y,
286
- min: Math.min(...states.map(s => stops[s])),
287
- max: Math.max(...states.map(s => stops[s])),
288
- tap: CARDDS_SHEET_TAP_REM * parseFloat(getComputedStyle(document.documentElement).fontSize),
289
- moved: false,
290
- };
291
- handle.setPointerCapture(e.pointerId);
292
- });
293
-
294
- document.addEventListener('pointermove', e => {
295
- const d = carddsSheetDrag;
296
- if (!d || !e.isPrimary) return;
297
- const dy = e.clientY - d.startY;
298
- if (!d.moved) {
299
- if (Math.abs(dy) < d.tap) return;
300
- d.moved = true;
301
- d.sheet.classList.add('is-dragging');
302
- }
303
- d.y = Math.min(Math.max(d.y0 + dy, d.min), d.max); // never above full, never below the lowest state
304
- d.sheet.style.setProperty('--_drag', (d.y - d.y0) + 'px');
305
- });
306
-
307
- function carddsSheetRelease() {
308
- const d = carddsSheetDrag;
309
- if (!d) return;
310
- carddsSheetDrag = null;
311
- d.sheet.classList.remove('is-dragging');
312
- if (!d.moved) return; // a tap: the click that follows cycles the state
313
- d.sheet.style.removeProperty('--_drag');
314
- const nearest = d.states.reduce((a, b) => Math.abs(d.stops[b] - d.y) < Math.abs(d.stops[a] - d.y) ? b : a);
315
- carddsSheetSet(d.sheet, nearest); // same state: --_drag is gone, so it eases back on its own
316
- carddsSheetSkipClick = d.handle;
317
- requestAnimationFrame(() => { carddsSheetSkipClick = null; });
318
- }
319
- document.addEventListener('pointerup', carddsSheetRelease);
320
- document.addEventListener('pointercancel', carddsSheetRelease);
321
-
322
- /* focus in a peeked sheet: a control under the cut cannot be used, so raise
323
- the sheet first — to its own state (the one a tap returns to) at least,
324
- further if that one has no room for the control under the pinned head (a
325
- small phone at a large text size). The browser's own reveal scrolled the body against the
326
- peek strip before this fired; start over from the top and let the reveal,
327
- with the raised state's scroll-padding, do the least. */
328
- document.addEventListener('focusin', e => {
329
- const sheet = e.target.closest?.('.sheet');
330
- if (!sheet || !sheet.classList.contains('sheet--peek') || e.target.closest('.sheet__handle')) return;
331
- const body = sheet.querySelector(':scope > .sheet__body');
332
- if (body) body.scrollTop = 0;
333
- carddsSheetReveal(e.target, carddsSheetHome(sheet));
334
- });
3
+ /* ---- the sheet is a React component now (src/sheets/Sheet.tsx, 2026-09-21) ----
4
+ cardds gives components; the app owns their state and wires them to each other. The handle's tap and
5
+ drag, the focus raise and the keyboard reveal are the Sheet's own behaviour and live in it; what one
6
+ component does to another — a button calling a sheet, a bar standing down beside one — is the app's.
7
+ What stays here is the environment: --kb. */
335
8
 
336
9
  /* --kb: the height of the viewport an on-screen keyboard covers. iOS keeps
337
10
  the layout viewport as it was and shrinks only the visual one, so a fixed
@@ -344,51 +17,12 @@ if (window.visualViewport) {
344
17
  const publish = () => {
345
18
  const covered = vv.scale > 1.01 ? 0 : Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
346
19
  document.documentElement.style.setProperty('--kb', covered + 'px');
347
- /* the keyboard just moved the cut: a sheet's box shrank with the step (or
348
- the viewport did), and the control being typed into may now sit under
349
- it. Half was enough before the keyboard; it may not be now — raise the
350
- sheet as far as its states go and reveal the control. This is the one
351
- listener that reads layout, and it reads it after the change it made. */
352
- carddsSheetReveal(document.activeElement);
353
20
  };
354
21
  vv.addEventListener('resize', publish);
355
22
  vv.addEventListener('scroll', publish);
356
23
  publish();
357
24
  }
358
25
 
359
- /* bring a control in a sheet into the visible part — the part of its body
360
- between the pinned head and the cut (the box's bottom edge, or the keyboard
361
- above it). The sheet goes to the lowest of its states, from the current one
362
- (or `atLeast`) upward, whose visible body has room for the control; if none
363
- has, to its highest; then the body scrolls the control into view. An app
364
- that moves the cut itself (its own keyboard handling, a bar it shows late)
365
- calls this with the control; the keyboard listener and the focus raise call
366
- it. It reads layout — after the change that moved the cut — and reasons
367
- from the resting offsets, so it is right mid-slide too. */
368
- function carddsSheetReveal(el, atLeast) {
369
- const sheet = el?.closest?.('.sheet');
370
- if (!sheet || el.closest('.sheet__handle')) return;
371
- el = el.closest('.field') ?? el; // the control's box, not the bare input inside a .field
372
- const states = carddsSheetStates(sheet);
373
- const from = Math.max(states.indexOf(carddsSheetState(sheet)), states.indexOf(atLeast), 0);
374
- const stops = carddsSheetStops(sheet);
375
- const sheetRect = sheet.getBoundingClientRect();
376
- const restTop = sheetRect.top - carddsSheetY(sheet); // where the sheet's top sits at full
377
- const bodyTop = (sheet.querySelector(':scope > .sheet__body') ?? sheet).getBoundingClientRect().top - sheetRect.top; // the scrollport, from the sheet's top
378
- const box = sheet.parentElement.getBoundingClientRect(); // the box that clips it: .step or .sheet-stage
379
- const vv = window.visualViewport;
380
- const cut = vv ? Math.min(box.bottom, vv.offsetTop + vv.height) : box.bottom;
381
- const foot = sheet.querySelector(':scope > .card__foot'); // the sheet's own button stands on the cut (sheet.css): the control has to clear it too
382
- const button = foot?.firstElementChild; // the foot is a layer as tall as the sheet: what the control must clear is its strip
383
- const need = (el.closest('.field') ?? el).getBoundingClientRect().height + (button ? box.bottom - button.getBoundingClientRect().top : 0); // from the button's top to the box's bottom edge, where it stands
384
- let to = states[states.length - 1];
385
- for (let i = from; i < states.length; i++) {
386
- if (cut - (restTop + stops[states[i]] + bodyTop) >= need) { to = states[i]; break; }
387
- }
388
- carddsSheetSet(sheet, to);
389
- el.scrollIntoView({ block: 'nearest' });
390
- }
391
-
392
26
  /* .bleed track: .is-scrolling while it scrolls (and a beat after), so its
393
27
  hairline scrollbar shows only while hovered or moving (card.css). */
394
28
  const carddsScrollTimers = new WeakMap();
@@ -399,80 +33,3 @@ document.addEventListener('scroll', e => {
399
33
  clearTimeout(carddsScrollTimers.get(t));
400
34
  carddsScrollTimers.set(t, setTimeout(() => t.classList.remove('is-scrolling'), 700));
401
35
  }, true);
402
-
403
- /* ---- Modal (layover.css, .sheet-stage--ask): a click on any button in the
404
- drawer closes the modal by playing its open animations backwards —
405
- Animation.reverse() on every animation in the card, the same keyframes, no
406
- closing keyframes to keep in step — then fires "cardds:modal" on the stage
407
- (bubbles, detail.answer = the button's data-answer) so the app removes the
408
- stage. Reduced motion (no animations): the event fires at once. */
409
- document.addEventListener('click', e => {
410
- const btn = e.target.closest('.modal__drawer button');
411
- if (!btn) return;
412
- const stage = btn.closest('.sheet-stage--ask');
413
- const modal = btn.closest('.modal');
414
- if (!stage || !modal || stage.classList.contains('is-closing')) return;
415
- stage.classList.add('is-closing');
416
- const anims = modal.getAnimations({ subtree: true });
417
- anims.forEach(a => a.reverse());
418
- Promise.all(anims.map(a => a.finished)).then(() => {
419
- stage.dispatchEvent(new CustomEvent('cardds:modal', { bubbles: true, detail: { answer: btn.dataset.answer ?? null } }));
420
- });
421
- });
422
-
423
- /* the two calls an app makes are on window: a bundler loads this file as a module, where a top-level
424
- function is not a global — and README has always said "call carddsSheetSet(sheet, state)". */
425
- if (typeof window !== 'undefined') Object.assign(window, { carddsSheetSet, carddsSheetReveal });
426
-
427
- /* ---- called sheets (sheet.css): a button in a step's words names a sheet with aria-controls ----
428
- (The base offers ways in — sign in · new phone · join — each sheet holds that way's whole form and
429
- its own submit on its foot.) A tap on it sends every other sheet of that step away and raises its own to the top state it snaps
430
- to (full) — from away, and from peek alike. The step then publishes --sheet-room, the strip the
431
- sheet keeps at peek, so the words can scroll their buttons clear of it (step.css); the buttons say
432
- aria-expanded. */
433
- function carddsSheetCall(sheet) {
434
- const step = sheet.parentElement;
435
- step.querySelectorAll(':scope > .sheet').forEach(s => { if (s !== sheet && carddsSheetState(s) !== 'away') carddsSheetSet(s, 'away'); });
436
- const states = carddsSheetStates(sheet);
437
- const top = states.includes('full') ? 'full' : states[states.length - 1];
438
- const was = carddsSheetState(sheet);
439
- if (was !== top) carddsSheetSet(sheet, top);
440
- if (was === 'away') sheet.querySelector(':scope > .sheet__handle')?.focus({ preventScroll: true });
441
- }
442
- function carddsStepSync(step) {
443
- const callers = [...step.querySelectorAll('[aria-controls]')].filter(b => document.getElementById(b.getAttribute('aria-controls'))?.matches('.step > .sheet'));
444
- if (!callers.length) return; // a plain step: one sheet, nothing calls it, nothing to publish
445
- const on = [...step.querySelectorAll(':scope > .sheet')].find(s => carddsSheetState(s) !== 'away');
446
- if (on) step.style.setProperty('--sheet-room', getComputedStyle(on).getPropertyValue('--sheet-peek')); // registered: reads back in px
447
- else step.style.removeProperty('--sheet-room');
448
- callers.forEach(b => b.setAttribute('aria-expanded', String(carddsSheetState(document.getElementById(b.getAttribute('aria-controls'))) !== 'away')));
449
- }
450
- document.addEventListener('click', e => {
451
- const caller = e.target.closest('[aria-controls]');
452
- const sheet = caller && document.getElementById(caller.getAttribute('aria-controls'));
453
- if (sheet && sheet.matches('.step > .sheet')) carddsSheetCall(sheet);
454
- });
455
- document.addEventListener('cardds:sheet', e => { const step = e.target.closest?.('.step'); if (step) carddsStepSync(step); });
456
-
457
- /* ---- bar guard: a bar is the base's, never in front of a sheet (actions.css, Lh 2026-09-20) ----
458
- A sheet on the screen covers the bar and CSS takes the pointer away; `inert` takes the keyboard and
459
- the accessibility tree too. Re-checked when a sheet changes state and when the page changes. */
460
- function carddsPagerGuard() {
461
- document.querySelectorAll('.action-bar').forEach((bar) => {
462
- const host = bar.parentElement;
463
- bar.inert = !!(host && host.querySelector('.step > .sheet:not(.sheet--away)'));
464
- });
465
- }
466
- if (typeof document !== 'undefined') {
467
- document.addEventListener('cardds:sheet', carddsPagerGuard);
468
- const start = () => {
469
- carddsPagerGuard();
470
- let queued = false;
471
- new MutationObserver(() => {
472
- if (queued) return;
473
- queued = true;
474
- requestAnimationFrame(() => { queued = false; carddsPagerGuard(); });
475
- }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
476
- };
477
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();
478
- }
package/css/actions.css CHANGED
@@ -239,14 +239,9 @@
239
239
  <button class="icon-btn icon-btn--invert" aria-label="ถัดไป">…</button>
240
240
  </div> — or the same two in an .action-bar__tier--pager under a control */
241
241
  .action-bar--pager { align-items: center; }
242
- /* The bar belongs to the BASE (Lh, 2026-09-20): it is the move of a page of words — a pager turning
243
- them, or one wide button. It is never in front of a sheet: a sheet that is on the screen covers it
244
- (step.css, "layers"), a peeking one included, and the button that finishes a sheet is the sheet's own
245
- foot (sheet.css). So a pager never works beside a sheet — that screen has work that cannot be walked
246
- past — and neither does any other bar: while a sheet is on (only .sheet--away, a sheet not called yet,
247
- is not), the bar takes no pointer here and cardds.js makes it inert, so the keyboard cannot reach what
248
- the eye cannot see. */
249
- :has(.step > .sheet:not(.sheet--away)) > .action-bar { pointer-events: none; }
242
+ /* The bar belongs to the BASE: the move of a page of words — a pager turning them, or one wide button. A screen
243
+ with a sheet finishes on the sheet's own foot instead (sheet.css). Guidance for composing, not a rule the
244
+ CSS enforces: a component never changes because another one is there. */
250
245
  /* the page dots: under the card in a .screen__centre group; tight gaps */
251
246
  .pager__at { flex: 0 1 auto; min-width: 0; overflow: hidden; display: flex; align-items: center; gap: var(--sp-1); }
252
247
  .pager__at > * {
package/css/card.css CHANGED
@@ -6,8 +6,8 @@
6
6
  .card {
7
7
  --card-bg: var(--card-1-bg); /* the slot, readable by children (avatar rings, fold) */
8
8
  --card-ink: var(--card-1-ink);
9
- --card-pad: var(--sp-5); /* published so .bleed can cancel it exactly */
10
- --card-gap: var(--sp-4); /* published so a sheet's peek strip (sheet.css) is exact */
9
+ --card-pad: var(--card-pad-rest); /* published so .bleed can cancel it exactly */
10
+ --card-gap: var(--card-gap-rest); /* published so a sheet's peek strip (sheet.css) is exact */
11
11
  background: var(--card-bg);
12
12
  color: var(--card-ink);
13
13
  border: var(--border-w) solid var(--card-border);
package/css/sheet.css CHANGED
@@ -28,7 +28,7 @@
28
28
  page. Many sheets stacked as the app's menu are the other system,
29
29
  .sheet-stack (stack.css): the same material, its own class and file.
30
30
 
31
- <article class="card sheet sheet--half" data-sheet-states="peek half 3q full">
31
+ <article class="card sheet sheet--half">
32
32
  A tap on the handle TOGGLES the sheet between its own state and peek
33
33
  (Lh 2026-09-16) — never a step-by-step climb; a drag snaps to any of
34
34
  the listed states, and the state it lands on becomes the one a tap
@@ -46,14 +46,12 @@
46
46
  ============================================================ */
47
47
 
48
48
  .sheet {
49
- --_handle: var(--sp-1);
50
49
  /* the strip a peeked sheet shows: the card's own top padding, the handle,
51
50
  the gap, one title row (--tap — the head rule below makes every head at
52
51
  least that tall) and the gap under it, so the cut lands exactly where
53
52
  the first control starts and nothing of it leaks through. Published
54
53
  (and registered in tokens.css) so a sheet whose head is taller can
55
54
  retune it: .my-sheet { --sheet-peek: … } */
56
- --sheet-peek: calc(var(--card-pad) + var(--_handle) + 2 * var(--card-gap) + var(--tap));
57
55
  --sheet-half: 0.5; /* a share of the sheet's own height */
58
56
  --sheet-3q: 0.25; /* three quarters showing: slid by a quarter */
59
57
  /* the sheet's own height, seen from inside it: the box it slides in is the
@@ -88,14 +86,16 @@
88
86
  .sheet--half { --_y: calc(var(--sheet-half) * 100%); --_cover: calc(var(--sheet-half) * var(--_box)); }
89
87
  .sheet--3q { --_y: calc(var(--sheet-3q) * 100%); --_cover: calc(var(--sheet-3q) * var(--_box)); }
90
88
  .sheet--peek { --_y: calc(100% - var(--sheet-peek)); --_cover: calc(var(--_box) - var(--sheet-peek)); }
89
+ /* --sheet-peek is declared on the BOX the sheet slides in, not on the sheet: the words behind a peeked sheet
90
+ have to read it too (step.css keeps that much room under them), and a token only flows down. The sheet
91
+ inherits it. A sheet whose head is taller retunes it on the box. */
92
+ :is(.step, .sheet-stage) { --_handle: var(--sp-1); --sheet-peek: calc(var(--card-pad-rest) + var(--_handle) + 2 * var(--card-gap-rest) + var(--tap)); }
93
+
91
94
  /* ---- called sheets (Lh, 2026-09-20): a .step whose .base-content has buttons that each CALL a sheet ----
92
95
  .sheet--away = not on the screen: slid all the way out (translate, like every state — the material never
93
96
  changes), and hidden once it has left so its shadow does not lie on the step's bottom edge; write it
94
- with `inert`. A button names its sheet — <button aria-controls="when"> … <article id="when" class="card
95
- sheet sheet--away" inert data-sheet-states="peek full"> — and cardds.js does the rest: a tap slides that
96
- sheet up to full and sends any other one away; the handle takes it down to peek, never further, so the
97
- words and the other buttons are back in reach; the same button again raises it from peek to full.
98
- Nothing waits at peek before the first call. No bar on such a screen (actions.css).
97
+ with `inert` (the React Sheet does). WHO brings it onto the screen is the app's business — a button, a route:
98
+ cardds ties no component to another. Nothing waits at peek before the first call.
99
99
  WHAT IT IS FOR: the base offers WAYS IN — sign in · new phone · join — and each button calls a sheet
100
100
  that holds that way's WHOLE form, its submit on the sheet's foot, leading to the next page. The base
101
101
  carries no control. It is NOT for a form taken apart — one sheet per field, a "send" left on the base:
package/css/step.css CHANGED
@@ -62,8 +62,9 @@
62
62
  :has(> .action-bar) .step { margin-bottom: calc(var(--bar-reserve) + var(--kb, 0rem)); }
63
63
  /* LAYERS (Lh, 2026-09-20): the base, then the sheet over it, then the top bar. A bar is the BASE's — the
64
64
  move of a page of words — so a step with a sheet has NO bar: the sheet finishes on its own .card__foot
65
- (sheet.css), and a form lives whole in ONE sheet, never item by item on the base. Should a bar be written
66
- beside a sheet anyway it stands down (actions.css + cardds.js) — a net for a mistake, not a layout. */
65
+ (sheet.css), and a form lives whole in ONE sheet, never item by item on the base. That is how cardds's
66
+ own screens are composed (the guide); the framework does not police it — a component never changes
67
+ because another one is there. */
67
68
 
68
69
  /* the reading block: a .base-content (base.css) — the same words-on-the-base
69
70
  block as anywhere else (it was .step__card, and a .card before 2026-09-16;
@@ -82,11 +83,10 @@
82
83
  color: var(--on-surface);
83
84
  }
84
85
  /* the reading block is the .base-content — or the .pages that holds several of them, a walk of words
85
- (card.css). --sheet-room: the strip a CALLED sheet keeps at peek (sheet.css, "called sheets"), published
86
- on the step by cardds.js while one is on the screen, so the buttons under it can scroll clear of it;
87
- nothing without one. */
86
+ (card.css). Behind a PEEKED sheet the words keep the strip's room under them, so whatever is down there
87
+ (the buttons that call the other sheets) scrolls clear of it — CSS alone: the box declares --sheet-peek. */
88
+ .step:has(> .sheet--peek) > :is(.base-content, .pages) { padding-bottom: var(--sheet-peek); }
88
89
  .step > :is(.base-content, .pages) {
89
- padding-bottom: var(--sheet-room, 0rem);
90
90
  flex: 0 1 auto;
91
91
  }
92
92
  .step > :is(.base-content, .pages) {
package/css/tokens.css CHANGED
@@ -49,6 +49,8 @@
49
49
  --chip-h: calc(var(--tap-base) * 19 / 24); /* 38 chip height */
50
50
  --bar-h: calc(var(--tap-base) * 4 / 3); /* 64 float bar, day cell, deck actions */
51
51
  --header-h: calc(var(--tap-base) * 5 / 4); /* 60 top bar — .topbar and .appbar alike */
52
+ --card-pad-rest: var(--sp-5); /* 20 a card's padding before any variant — .card publishes it as --card-pad; the sheet's peek strip is built from it where no card is in scope */
53
+ --card-gap-rest: var(--sp-4); /* 16 …and its gap */
52
54
  --screen-pad: var(--sp-4); /* 16 the screen's inset — .sheet-stack cancels it */
53
55
  --map-ring: calc(var(--tap-base) * 10 / 3); /* the rough-area ring on a map (sheet.css .map__ring) — a neighbourhood, not a house */
54
56
  --fab: calc(var(--tap-base) * 3 / 2); /* 72 square floating action */