@usableapp/cardds 0.7.66 → 0.7.68

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,30 @@
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.68 — 2026-10-07
6
+
7
+ ### Changed
8
+ - **A `Sticky` has an edge, and a short shadow** (Lh 2026-10-07) — a hairline edge runs round the note in its own shape (the folded corner, a `corner` notch), so a pale note still ends somewhere on a pale ground; the shadow is short and close under the note. Before: one wide soft shadow all round (8px of blur), which swallowed the edge of a pale note. Nothing to change in a screen. BREAKING for a theme: the knob `--sticky-drop` is gone — set `--sticky-edge-bg` (the edge's colour), `--sticky-shadow-bg` (the shadow's colour) and `--sticky-shadow-blur` instead.
9
+ - **In a chat, a note leans on its writer's own side** (Lh 2026-10-07) — in a `StickyThread` without `rail`, a note starts at the thread's edge under the avatar: theirs against the left, mine (`side="right"`) against the right. Before: notes started under the name, so both sides stood almost one over the other in the middle. A thread WITH `rail` is unchanged (the notes keep clear of the dotted line). Nothing to change in a screen.
10
+ - **A `Stamp` has fewer, larger teeth** (Lh 2026-10-07) — 12 across and 15 down; it was 20 and 25, which ran together into a fuzzy edge on a small stamp. Nothing to change in a screen.
11
+
12
+ ### Fixed
13
+ - **Safari / iPhone: a dragged `StampOn` left its shadow behind as a trail, and the note under it showed a hard grey band** — seen on the iOS Simulator. A draggable stamp is on its own layer now, and the note has no filter (see the edge and shadow above). Nothing to change in a screen.
14
+
15
+ ## 0.7.67 — 2026-10-07
16
+
17
+ ### Added
18
+ - **Stamps: `Stamp`, `StampPlace`, `StampBook` + `StampCell`, `StampOn`** (Lh 2026-10-07) — a perforated 4:5 paper stamp a member keeps and sticks on a note or a postcard. `<Stamp pictures={[…]} tone="custard" name="ต้น" words="กาแฟ · หมา" />`: `pictures` is one to four nodes of the app's own art, and the stamp places, sizes and turns them by how many there are. `tone` is one of `STICKY_TONES` (the same knobs as a note's paper). `tilt`, and `onClick` makes the whole stamp a button. It fills its column up to `--stamp-max-w`; in a `Centre` it is `--stamp-centre-max-w` (56cqw) with its words centred under it. `StampPlace` is an empty dashed place. `StampBook places={6}` is the album page, three `StampCell label onClick on` across; `strip` is one scrolling line of small stamps. `StampOn at={{ x, y }} turn onAtChange` is a stamp stuck on a surface: with `onAtChange` the member drags it (arrow keys too) and it asks on letting go, without it the stamp is still. The picture set, the words and the random ground are the app's: store `{ pictures, ground, name, words }` and the stamp draws the same every time. Gallery group `stamps`, screens on `StampScreens` (the first stamp on a home, one from a neighbour, making your own from up to four chips, a note with stamps).
19
+ - **A stamp shows its words in the album and its pictures only once stuck** (Lh 2026-10-07) — inside a `StampOn` the `name` and `words` are not drawn and the pictures move to the middle of the frame. For any other place a stamp is stuck, pass `<Stamp bare>`.
20
+ - **`Sticky stuck`** (Lh 2026-10-07) — things stuck on the note, each placing itself over the paper: `<Sticky stuck={<StampOn …><Stamp … /></StampOn>}>`. A tap between them still reaches the words. Before: nothing could lie on a note.
21
+ - **`Mark`: a highlighter stroke over a word in a sentence** (Lh 2026-10-07) — `<Text level="body"><Mark>ธนาคารเวลา</Mark> ส่งแสตมป์ดวงแรกให้คุณ</Text>`. Green (`--signal-ok`) by default, `tone="warn" | "error" | "info"` for the others; `--mark-bg` / `--mark-ink` / `--mark-radius` retune it. Drawn as a pen would: four unlike corners, the ink darker at its ends and along its middle. It follows the words across a line break and is never a control.
22
+ - **Postcards: `PostLetter`, `PostDesk`, `PostBox`, `Postmark`** (Lh 2026-10-07) — a postcard read, written and kept. Nothing flips; the older `Postcard` (a cover card that turns over) is unchanged and shares nothing with these. `PostLetter`: two upright sheets, the picture and the written side (address over message, the stamp and its postmark on the corner); the tab or a tap turns it — `face` + `onFaceChange`; `voice` + `playing` / `onPlayingChange` puts a play control on the picture (the app plays and passes the time). `PostDesk`: the paper is twice the screen, one half in sight (`half`); typing stops when the half is full (`onLinesChange`, `onFull`); the sender picks one of their `stamps` (`stamp` + `onStampChange`); the app sets `state="struck"` from its own button, the postmark lands, the desk asks for `sent` and the card leaves. `PostBox` + `PostBoxCard`: kept cards one behind another, an edge parts the box (`open` = the card's `id`, `null` = the front card). `Postmark`: `place`, `date`. The stamp is always a `Stamp` you pass; on a card it shows its pictures only. Gallery group `postcards`, the three finished screens on `PostScreens`.
23
+ - **`Page bleed`** (Lh 2026-10-07) — in a `Step` the page runs to the screen's edges and keeps the inset as its own padding: a card that leans, slides or casts a shadow is cut at the glass, not at the inset, and nothing pans sideways. Before: such a card was cut 16px in. A plain `Page` is unchanged.
24
+ - **Icon `pause`**, and cardds's own words for the post (`play`, `pause`, `to`, `turnToBack`, `turnToPicture`, `message`, `address`, `toMessage`, `toAddress`, `stampOn`, `stampOff`, `stamps`) in English and Thai — `setWords` retunes them.
25
+
26
+ ### Changed
27
+ - **Every `Sticky` is one height** (Lh 2026-10-07) — a note is at least 211px tall at the defaults (`--tap` × 4.4, the height of a note being written), whatever it says; more words still grow it. Before: a note hugged its words, so a one-line note in a `StickyThread` was about 89px. A feed of short notes is now taller: about three notes to a screen. To get the hugging note back, set `--sticky-min-h: 0` in your `theme.css` or on the thread. A `square` note is unchanged.
28
+
5
29
  ## 0.7.66 — 2026-10-06
6
30
 
7
31
  ### Added
package/README.md CHANGED
@@ -484,6 +484,66 @@ between at 40% down the screen (see *Anatomy*, "Where the centre is"):
484
484
 
485
485
  Live: every screen in the gallery (`npm run dev`).
486
486
 
487
+ ## Stamps
488
+
489
+ A stamp is a small paper picture a member keeps and sticks on a note or a postcard for a neighbour. Sending never uses
490
+ it up. cardds draws the paper; the pictures, their words and which stamp a member owns are the app's.
491
+
492
+ - **`Stamp`** — a perforated 4:5 stamp. `pictures` is one to four nodes, the app's own art (an svg, an img), in placement
493
+ order: the stamp places, sizes and turns them by how many there are. `tone` is the ground, one of `STICKY_TONES` (the
494
+ same list and the same `--sticky-<tone>-bg` knobs as a note's paper). `name` is the big line on the picture's foot,
495
+ `words` the small line above it. `tilt` turns it, `onClick` makes the whole stamp a button. It fills its column up to
496
+ `--stamp-max-w`, and every inner length follows its width, so one stamp reads in a tray and in a sheet.
497
+ - **`StampPlace`** — an empty dashed place the size of a stamp: a word inside, `onClick` for "make yours", `faint` for a
498
+ gap nobody is asked to fill.
499
+ - **`StampBook`** + **`StampCell`** — the album page: cells three across on an album ground, a `label` under each stamp,
500
+ the free places drawn up to `places`. A cell with `onClick` is a button; `on` marks it pressed. `strip` is one line
501
+ of small stamps that scrolls sideways, the tray a member picks from.
502
+ - **`StampOn`** — a stamp stuck on a surface. `at` is the stamp's middle as a share of the surface (`{ x, y }`, 0 to 1),
503
+ `turn` its angle in degrees. With `onAtChange` the member drags it (the arrow keys move it too) and it asks with the
504
+ new place when the finger lets go; without it the stamp is still. The surface is the nearest positioned box:
505
+ `Sticky stuck={…}` is one.
506
+
507
+ What an app stores for a stamp: the picture ids (1 to 4, in order), the ground tone, the name, the small line. Making
508
+ one is the app's screen: `ChipPick` checkboxes that each show a picture, the app refuses a fifth, and the app draws
509
+ ONE of the picked chips at random for the ground and keeps it, so the stamp renders the same every time. The gallery's
510
+ `StampScreens` page has the four screens: the first stamp on a home, one from a neighbour, make your own, a note with
511
+ stamps.
512
+
513
+ **`Mark`** is a highlighter stroke over a word inside running text: `<Text level="body"><Mark>ธนาคารเวลา</Mark> ส่ง…</Text>`.
514
+ Green (`--signal-ok`) unless `tone` names another signal; `--mark-bg` / `--mark-ink` retune it. It is information, never
515
+ a control, and never a chip or a line of its own.
516
+
517
+ ## Postcards
518
+
519
+ A postcard is the one piece of mail with no envelope: the written side is the card, and the corner (a stamp, the postmark
520
+ struck over its edge) proves the trip. Three components, one for each thing a member does with one. None of them flips;
521
+ the older `Postcard` (a member's cover card that turns over) is another component and shares nothing with these.
522
+
523
+ - **`PostLetter`**: reading one that arrived. Two upright 2:3 sheets, one in front. The picture side is `picture` (the
524
+ app's img or svg) in a white margin with `caption` under it; the written side is the address (`kicker`, `to` lines) over
525
+ the message (`children`, set large, `sign` at its end), with `stamp` and `postmark` on the corner. The tab over the
526
+ card, or a tap on the card, turns it: the sheet in front swings to its edge and the other swings in. `face` +
527
+ `onFaceChange`, or `defaultFace`. `voice` is a voice note's time as the app words it; with it the picture side has a
528
+ play control (`playing` + `onPlayingChange`). cardds never plays sound: the control asks, the app plays and passes the
529
+ time. `journey` is one line under the card.
530
+ - **`PostDesk`**: writing one. The paper is landscape and twice the screen, and the phone looks at one half (`half` +
531
+ `onHalfChange`): the message on ruled lines, then the address (`address`, a few `PostDeskLine`s) and the stamp box.
532
+ Typing stops when the half is full; `onLinesChange(left, fits)` and `onFull` let the app word its `note`. `stamps` is
533
+ the sender's own stamps (`{ id, stamp, label }`), shown under the card; `stamp` + `onStampChange` is the one in the
534
+ box. Sending is the screen's button, never the desk's: the app sets `state="struck"`, the `postmark` lands, the desk
535
+ asks for `sent`, the card leaves upward and `sent` stands in its place.
536
+ - **`PostBox`** + **`PostBoxCard`**: keeping many. Cards whole and landscape, one behind another, each showing its top
537
+ edge (`from`, `when`, `stamp`, `postmark`). The last child stands in front, nearest the thumb. Pressing an edge parts
538
+ the box at that card (`open` is its `id`, `null` is the front card; `onOpenChange`).
539
+ - **`Postmark`**: the round mark, `place` and `date`. Information, nothing to press.
540
+
541
+ The stamp is always a `Stamp` the app hands over. On a card's corner it shows its pictures only (pass `bare`; the corner
542
+ drops the words by itself too); in the desk's tray it keeps its words. `PostLetter` and `PostDesk` size themselves to the
543
+ screen they are in, so they go in a `Step` > `Page bleed`. **`Page bleed`** runs the page to the screen's edges and keeps
544
+ the inset as its own padding, so a card that leans, slides or casts a shadow is cut at the glass, not at the inset.
545
+ The gallery's `PostScreens` page has the three finished screens (read, write, keep) to start from.
546
+
487
547
  ## Rules of the system
488
548
 
489
549
  1. **One card = one boundary.** A card frames one thing: a category, a stat
package/cardds.css CHANGED
@@ -15,6 +15,8 @@
15
15
  @import url("css/choice.css"); /* check, toggle, chip grid, days, calendar, mood, pin, composer */
16
16
  @import url("css/lists.css"); /* row, rows, key-value, kv grid, timeline, legend, link */
17
17
  @import url("css/sticky.css"); /* sticky note, its field, tone chooser, thread of posts, pile */
18
+ @import url("css/stamp.css"); /* stamp, empty place, album page, a stamp stuck on a surface */
19
+ @import url("css/post.css"); /* the post: postmark, a postcard read (letter), written (desk), kept (box) */
18
20
  @import url("css/media.css"); /* cover card, quote, tile grid, fold, mosaic, wave */
19
21
  @import url("css/layover.css"); /* ask drawer, popover menu, banner, float bar, callout */
20
22
  @import url("css/finish.css"); /* light, shadow, edges — only under data-finish="lit" */
package/css/actions.css CHANGED
@@ -364,12 +364,12 @@
364
364
  pointer, no press, --off. Zero specificity everywhere: a component's own rule always wins. A NEW control joins a list here. */
365
365
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
366
366
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
367
- a.avatar, button.avatar, .menu__item, button.snackbar__body) {
367
+ a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach) {
368
368
  cursor: pointer;
369
369
  touch-action: manipulation;
370
370
  transition: scale var(--motion-press) ease-out;
371
371
  }
372
- :where(a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field) {
372
+ :where(a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field, .post-box__edge) {
373
373
  cursor: pointer;
374
374
  touch-action: manipulation;
375
375
  transition: opacity var(--motion-press) ease-out;
@@ -383,27 +383,27 @@
383
383
  }
384
384
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
385
385
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
386
- a.avatar, button.avatar, .menu__item, button.snackbar__body):active:not(:disabled, [aria-disabled="true"], :has(:disabled)) { scale: var(--press-scale); }
387
- :where(a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field):active:not([aria-disabled="true"], :has(:disabled)) { opacity: var(--press-dim); }
386
+ a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach):active:not(:disabled, [aria-disabled="true"], :has(:disabled)) { scale: var(--press-scale); }
387
+ :where(a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field, .post-box__edge):active:not([aria-disabled="true"], :has(:disabled)) { opacity: var(--press-dim); }
388
388
  .sheet-step:has(> .sheet-step__go:active) { opacity: var(--press-dim); } /* the step bar's button is invisible over it: the bar answers */
389
- /* SHADE — what has something BEHIND it (a peeked sheet over the pile, a postcard's face over its back) must not turn see-through:
389
+ /* SHADE — what has something BEHIND it (a peeked sheet over the pile, a postcard's face over its back, a post letter's sheet over the other) must not turn see-through:
390
390
  it darkens a touch instead (--press-shade) */
391
- :where(.sheet-stack--tap > .card:not(.is-open), .postcard__turn) { cursor: pointer; touch-action: manipulation; transition: filter var(--motion-press) ease-out; }
392
- :where(.sheet-stack--tap > .card:not(.is-open), .postcard__turn):active { filter: brightness(var(--press-shade)); }
391
+ :where(.sheet-stack--tap > .card:not(.is-open), .postcard__turn, .post-letter__sheet:not(:has(.icon-btn:active))) { cursor: pointer; touch-action: manipulation; transition: filter var(--motion-press) ease-out; }
392
+ :where(.sheet-stack--tap > .card:not(.is-open), .postcard__turn, .post-letter__sheet:not(:has(.icon-btn:active))):active { filter: brightness(var(--press-shade)); }
393
393
  /* the focus ring: on the control, or on the label that wraps a hidden input (chip, tile, file button, a row that is a label) */
394
394
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
395
395
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
396
- a.avatar, button.avatar, .menu__item, button.snackbar__body):focus-visible,
396
+ a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach):focus-visible,
397
397
  :where(.chip--pick, .tile--pick, label.btn, label.row, .toggle-field):has(:focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
398
- :where(a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field):not(label, .toggle-field):focus-visible { outline: var(--focus); outline-offset: var(--sp-1); }
398
+ :where(a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field, .post-box__edge):not(label, .toggle-field):focus-visible { outline: var(--focus); outline-offset: var(--sp-1); }
399
399
  /* disabled: no pointer anywhere; a control fades (a link and a row keep their own muted look) */
400
400
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
401
401
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
402
- a.avatar, button.avatar, .menu__item, button.snackbar__body, a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field):is(:disabled, [aria-disabled="true"]),
402
+ a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach, a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field, .post-box__edge):is(:disabled, [aria-disabled="true"]),
403
403
  :where(.chip--pick, .tile--pick, .mood__opt, .toggle-field, label.btn, label.row):has(:disabled) { cursor: default; }
404
404
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
405
405
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
406
- a.avatar, button.avatar, .menu__item, button.snackbar__body):not(.link):is(:disabled, [aria-disabled="true"]),
406
+ a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach):not(.link):is(:disabled, [aria-disabled="true"]),
407
407
  :where(.chip--pick, .tile--pick, .mood__opt, .toggle-field, label.btn):has(:disabled) { opacity: var(--off); }
408
408
  .sheet-stack--tap > .card > .card__head > :is(h1, h2, h3):focus-visible { outline: var(--focus); outline-offset: var(--sp-1); } /* the keyboard's way into a stack sheet */
409
409
  @media (prefers-reduced-motion: reduce) { :root { --press-scale: 1; } } /* the give goes; the dim, the shade and the focus ring stay */
package/css/base.css CHANGED
@@ -144,6 +144,28 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
144
144
  .card .t-muted { color: var(--card-muted); } /* muted follows the card's ink, not the surface's */
145
145
  /* .card--3 .t-muted (dim instead of --card-muted on the dark slot) is declared once in card.css, with the rest of that family */
146
146
  .t-caps { text-transform: uppercase; }
147
+ /* ---- mark: a highlighter stroke over a word INSIDE running text (a sender's name in a greeting) ----
148
+ <p class="t-body"><mark class="mark">ธนาคารเวลา</mark> ส่งแสตมป์ดวงแรกให้คุณ</p>
149
+ The words keep their ink and their size; the stroke is the signal washed thin, drawn as a pen would: unlike corners, the ink
150
+ darker at its ends and along its middle. It follows the words across a line break (each piece is its own stroke). Information, never a control. Green (ok) unless a tone says otherwise. */
151
+ .mark {
152
+ --_bg: color-mix(in srgb, var(--signal-ok) 30%, transparent);
153
+ --_pool: color-mix(in srgb, var(--mark-bg, var(--_bg)) 75%, transparent); /* the same ink again, thinner: where it lies twice it reads darker, as wet ink does */
154
+ --_shape: 0.75em 0.3em 0.9em 0.35em / 0.4em 0.95em 0.35em 0.8em; /* four unlike corners: a stroke drawn by hand has no two ends alike */
155
+ background-color: var(--mark-bg, var(--_bg));
156
+ background-image:
157
+ linear-gradient(100deg, var(--_pool), transparent 0.55em), /* ink pools where the pen lands */
158
+ linear-gradient(280deg, var(--_pool), transparent 0.4em), /* and a little where it lifts */
159
+ linear-gradient(181deg, transparent 18%, var(--_pool) 52%, transparent 88%); /* the nib presses harder along its middle */
160
+ color: var(--mark-ink, inherit);
161
+ padding-inline: var(--mark-pad-x, 0.3em);
162
+ border-radius: var(--mark-radius, var(--_shape));
163
+ -webkit-box-decoration-break: clone;
164
+ box-decoration-break: clone;
165
+ }
166
+ .mark--warn { --_bg: color-mix(in srgb, var(--signal-warn) 30%, transparent); }
167
+ .mark--error { --_bg: color-mix(in srgb, var(--signal-error) 30%, transparent); }
168
+ .mark--info { --_bg: color-mix(in srgb, var(--signal-info) 30%, transparent); }
147
169
  .grow { flex: 1; min-width: 0; } /* the child that takes the remaining row space */
148
170
  /* read, not shown (Lh 2026-09-23): words a screen reader needs — a page's h1 the design leaves out — kept in the page, taking no room.
149
171
  The 1px is the one the platform needs to keep it in the accessibility tree, not a length of the design. */
package/css/post.css ADDED
@@ -0,0 +1,326 @@
1
+ /* cardds — the post: a postcard read (post-letter), written (post-desk) and kept (post-box), the postmark, the card stock.
2
+ Not the `Postcard` of media.css (a member's cover card that flips) — nothing here flips, and no class is shared with it.
3
+ The written side is the postcard; the corner — a stamp, the postmark struck over its edge — proves the journey. The stamp is
4
+ always a `Stamp` (stamp.css), handed over by the app. */
5
+
6
+ /* ---- post-paper: the card stock. Its own ground and ink, wherever it lies; its parts read --_muted and --_rule ---- */
7
+ .post-paper {
8
+ background: var(--post-paper-bg, var(--card-1-bg));
9
+ color: var(--post-paper-ink, var(--card-1-ink));
10
+ border-radius: var(--post-paper-radius, var(--sp-2));
11
+ box-shadow: var(--post-paper-shadow, var(--shadow-card));
12
+ --_muted: var(--post-paper-muted-ink, var(--card-muted));
13
+ --_rule: var(--post-paper-rule-ink, var(--ink-rail));
14
+ }
15
+
16
+ /* ---- postmark: the round mark struck by the post — where, when — two rings, turned a little, its cancel lines running on
17
+ <div class="postmark"><span>จันทบุรี</span><span>12 เม.ย.</span></div> ---- */
18
+ .postmark {
19
+ --_base: calc(var(--tap) * 1.75);
20
+ position: relative;
21
+ z-index: 1;
22
+ flex: none;
23
+ display: grid;
24
+ place-content: center;
25
+ justify-items: center;
26
+ width: var(--postmark-size, var(--_base));
27
+ --_size: var(--postmark-size, var(--_base));
28
+ aspect-ratio: 1;
29
+ border: var(--rule-w) solid currentColor;
30
+ border-radius: 50%;
31
+ outline: var(--border-w) solid currentColor;
32
+ outline-offset: calc(-1 * var(--sp-1) - var(--rule-w));
33
+ color: var(--postmark-ink, color-mix(in srgb, currentColor 72%, transparent));
34
+ font: var(--postmark-font, var(--type-label));
35
+ text-align: center;
36
+ rotate: var(--postmark-tilt, -10deg);
37
+ }
38
+ .postmark::after { /* the cancel lines that run on over the stamp */
39
+ content: "";
40
+ position: absolute;
41
+ inset-inline-start: 100%;
42
+ inset-block-start: 28%;
43
+ width: 46%;
44
+ height: calc(var(--sp-3) * 2 + var(--rule-w));
45
+ background: repeating-linear-gradient(to bottom, currentColor 0 var(--rule-w), transparent var(--rule-w) var(--sp-3));
46
+ }
47
+ .postmark > span { display: block; white-space: nowrap; }
48
+
49
+ /* ---- post-corner: the corner that proves the journey — the stamp, and the postmark struck over its edge
50
+ <div class="post-corner"><div class="postmark">…</div><span class="post-corner__stamp"><span class="stamp">…</span></span></div>
51
+ A stamp stuck here shows its pictures only, as one in a `.stamp-on` does (stamp.css `--bare`): the words belong to the album. ---- */
52
+ .post-corner { display: flex; align-items: flex-end; justify-content: flex-end; flex: none; }
53
+ .post-corner > .postmark { margin-inline-end: calc(var(--_size) * -0.4); margin-block-end: calc(var(--sp-2) * -1); }
54
+ .post-corner__stamp {
55
+ --_w: calc(var(--sp-2) * 9);
56
+ position: relative;
57
+ flex: none;
58
+ display: grid;
59
+ width: var(--post-corner-stamp-w, var(--_w));
60
+ aspect-ratio: 4 / 5;
61
+ rotate: var(--post-corner-stamp-tilt, 2deg);
62
+ }
63
+ .post-corner__stamp > :is(.stamp, .stamp-place) { grid-area: 1 / 1; max-width: none; }
64
+
65
+ /* ============================================================
66
+ post-letter — READING one that arrived. The written side stood upright: the address half over the message half.
67
+ The picture is the other sheet; the tab over the pair brings it forward. Two rigid sheets changing places —
68
+ PostLetter swings the one in front to its edge, then the other in (Web Animations; nothing with reduced motion).
69
+ <div class="post-letter post-letter--picture">
70
+ <div class="post-letter__sheet post-letter__sheet--picture post-paper">
71
+ <div class="post-letter__pic">…</div><p class="post-letter__caption">…</p>
72
+ <div class="post-letter__voice"><button class="icon-btn icon-btn--invert">…</button><span class="post-letter__time">0:12</span></div>
73
+ </div>
74
+ <article class="post-letter__sheet post-letter__sheet--back post-paper" inert>
75
+ <header class="post-letter__head">
76
+ <div class="post-letter__to"><span class="post-letter__kicker">…</span>
77
+ <span class="post-letter__lines"><span class="post-letter__line post-letter__line--name"><small>ถึง</small>ฝน</span><span class="post-letter__line">…</span></span></div>
78
+ <div class="post-corner">…</div>
79
+ </header>
80
+ <div class="post-letter__message"><p>…</p><p class="post-letter__sign">…</p></div>
81
+ </article>
82
+ <button class="post-letter__turn">…icon…<span>พลิกดูด้านหลัง</span></button>
83
+ <p class="post-letter__journey">…</p>
84
+ </div>
85
+ ============================================================ */
86
+ .post-letter {
87
+ --_room: calc(var(--tap) + var(--sp-5));
88
+ position: relative;
89
+ display: grid;
90
+ padding-block-start: var(--post-letter-peek, var(--tap));
91
+ --_peek: var(--post-letter-peek, var(--tap));
92
+ /* as wide as leaves the 2:3 sheet, the tab and the journey line whole above the bottom layer */
93
+ width: min(100%, calc((100cqh - var(--bottom-h, 0rem) - var(--_peek) - var(--sp-6) - var(--post-letter-journey-room, var(--_room))) * 2 / 3));
94
+ margin-inline: auto;
95
+ }
96
+ .post-letter__sheet {
97
+ position: relative;
98
+ grid-area: 1 / 1;
99
+ aspect-ratio: 2 / 3;
100
+ min-width: 0;
101
+ min-height: 0;
102
+ overflow: clip;
103
+ visibility: hidden;
104
+ rotate: calc(-1 * var(--post-letter-tilt, 2deg));
105
+ }
106
+ .post-letter__sheet--back { rotate: var(--post-letter-tilt, 2deg); } /* turned over, the lean is the other way */
107
+ .post-letter--back > .post-letter__sheet--back,
108
+ .post-letter--picture > .post-letter__sheet--picture { visibility: visible; }
109
+ .post-letter:not(:has(> .post-letter__journey)) { --_room: 0rem; } /* no journey line: its room goes back to the sheet */
110
+ .post-letter__journey { grid-area: 2 / 1; margin: var(--sp-3) 0 0; text-align: center; font: var(--type-caption); color: var(--post-letter-journey-ink, var(--on-surface-muted)); }
111
+
112
+ /* the picture side: the print with its white margin, the place printed under it */
113
+ .post-letter__sheet--picture {
114
+ display: grid;
115
+ grid-template-rows: minmax(0, 1fr) auto;
116
+ gap: var(--sp-2);
117
+ padding: var(--post-letter-picture-pad, var(--sp-3));
118
+ --_pad: var(--post-letter-picture-pad, var(--sp-3));
119
+ }
120
+ .post-letter__pic { min-height: 0; border-radius: var(--sp-1); overflow: clip; }
121
+ .post-letter__pic > * { display: block; width: 100%; height: 100%; object-fit: cover; }
122
+ .post-letter__caption { margin: 0; min-height: 1.35em; font: var(--type-caption); color: var(--_muted); }
123
+ /* the voice on the picture side: the play button over the print's corner, its time under it in the margin */
124
+ .post-letter__voice { position: absolute; inset-block-end: var(--_pad); inset-inline-end: calc(var(--_pad) + var(--sp-2)); display: grid; justify-items: center; gap: var(--sp-2); cursor: auto; }
125
+ .post-letter__time { font: var(--type-caption); font-variant-numeric: tabular-nums; color: var(--_muted); }
126
+
127
+ /* the written side */
128
+ .post-letter__sheet--back { display: flex; flex-direction: column; gap: var(--post-letter-gap, var(--sp-4)); padding: var(--post-letter-pad, var(--sp-5)); }
129
+ .post-letter__head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: var(--sp-2); padding-block-end: var(--sp-4); border-block-end: var(--rule-w) solid var(--_rule); }
130
+ .post-letter__to { display: grid; gap: var(--sp-2); min-width: 0; align-self: stretch; align-content: space-between; }
131
+ .post-letter__kicker { font: var(--type-caption); color: var(--_muted); letter-spacing: 0.08em; }
132
+ .post-letter__lines { display: grid; gap: var(--sp-1); }
133
+ .post-letter__line { display: block; padding-block: var(--sp-1); border-block-end: var(--border-w) solid var(--_rule); font: var(--type-caption); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
134
+ .post-letter__line--name { font: var(--type-title); }
135
+ .post-letter__line > small { font: var(--type-caption); color: var(--_muted); margin-inline-end: var(--sp-2); }
136
+ .post-letter__message {
137
+ flex: 1;
138
+ min-height: 0;
139
+ display: flex;
140
+ flex-direction: column;
141
+ gap: var(--sp-3);
142
+ font: var(--post-letter-hand-font, var(--type-h2));
143
+ font-size: var(--post-letter-hand-font-size, var(--fs-lg));
144
+ line-height: var(--post-letter-hand-leading, 1.6);
145
+ }
146
+ .post-letter__message > p { margin: 0; }
147
+ .post-letter__sign { align-self: flex-end; }
148
+
149
+ /* the tab: the showing edge of the sheet behind is the control that brings it forward. A chip in the strip over the pair;
150
+ its tap target is the strip's whole height (the ::after). The press and the ring are the PRESS list's (actions.css) */
151
+ .post-letter__turn {
152
+ position: absolute;
153
+ inset-block-start: calc(var(--_peek) / 2);
154
+ inset-inline-start: var(--sp-2);
155
+ translate: 0 -50%;
156
+ z-index: 2;
157
+ display: inline-flex;
158
+ align-items: center;
159
+ gap: var(--sp-2);
160
+ padding: var(--sp-1) var(--sp-3);
161
+ border: 0;
162
+ border-radius: var(--r-chip);
163
+ background: var(--post-letter-tab-bg, var(--action-bg));
164
+ color: var(--post-letter-tab-ink, var(--action-ink));
165
+ font: var(--type-label);
166
+ box-shadow: var(--shadow-float);
167
+ }
168
+ .post-letter__turn::after { content: ""; position: absolute; inset-inline: 0; inset-block-start: 50%; height: var(--_peek); translate: 0 -50%; }
169
+
170
+ /* ============================================================
171
+ post-desk — WRITING one. The paper stays landscape and twice the screen; the phone looks at one half. Half of a 3:2 card
172
+ is a 3:4 panel, so each half fills the hand: write on the left, slide the paper, address and stamp the right.
173
+ The stamp box is a button; struck, the postmark lands on it; sent, the card leaves upward. Lives in a `Page bleed`.
174
+ <div class="post-desk post-desk--message post-desk--writing">
175
+ <div class="post-desk__window">
176
+ <div class="post-desk__card post-paper">
177
+ <section class="post-desk__half post-desk__half--message"><textarea class="post-desk__write"></textarea></section>
178
+ <section class="post-desk__half post-desk__half--address" inert>
179
+ <div class="post-desk__corner"><div class="post-corner">…postmark…<button class="post-corner__stamp post-desk__slot" aria-pressed="false">…</button></div></div>
180
+ <div class="post-desk__lines"><label class="post-desk__line"><span>ถึง</span><input></label>…</div>
181
+ </section>
182
+ </div>
183
+ <button class="post-desk__reach post-desk__reach--address"></button>
184
+ <div class="post-desk__gone">…</div>
185
+ </div>
186
+ <p class="post-desk__note">…</p>
187
+ <div class="post-desk__stamps" role="group"><button class="post-desk__pick" aria-pressed="false">…stamp…</button>…</div>
188
+ </div>
189
+ ============================================================ */
190
+ .post-desk { container-type: inline-size; display: grid; gap: var(--post-desk-gap, var(--sp-3)); }
191
+ .post-desk__window { position: relative; min-width: 0; }
192
+ .post-desk__card {
193
+ --_peek: var(--post-desk-peek, var(--sp-6));
194
+ --_p: min(calc(100cqi - var(--_peek)), calc((100cqh + var(--kb, 0rem) - var(--bottom-h, 0rem) - var(--sp-12) * 2) * 0.75)); /* + --kb: the screen's box shrinks under a keyboard, the paper never does — what was written must still fit */
195
+ --_x: 0rem;
196
+ --_y: 0rem;
197
+ display: grid;
198
+ grid-template-columns: 1fr 1fr;
199
+ width: calc(var(--_p) * 2);
200
+ aspect-ratio: 3 / 2;
201
+ translate: var(--_x) var(--_y);
202
+ transition: translate var(--motion-sheet) ease, rotate var(--motion-sheet) ease;
203
+ }
204
+ .post-desk--address .post-desk__card { --_x: calc(100cqi - var(--_p) * 2); }
205
+ .post-desk--sent .post-desk__card { --_y: -160%; rotate: -5deg; }
206
+ .post-desk__half { min-width: 0; min-height: 0; padding: var(--post-desk-pad, var(--sp-5)); display: flex; flex-direction: column; }
207
+ .post-desk__half--address { border-inline-start: var(--rule-w) solid var(--_rule); gap: var(--sp-4); }
208
+ .post-desk__write {
209
+ flex: 1;
210
+ width: 100%;
211
+ min-height: 0;
212
+ resize: none;
213
+ border: 0;
214
+ outline: none;
215
+ background: transparent;
216
+ color: inherit;
217
+ font: var(--post-desk-hand-font, var(--type-h2));
218
+ line-height: calc(var(--fs-body) * var(--post-desk-leading, 1.7));
219
+ --_line: calc(var(--fs-body) * var(--post-desk-leading, 1.7));
220
+ /* the ruled lines: one under every line of writing */
221
+ background-image: linear-gradient(to bottom, transparent calc(100% - var(--border-w)), var(--_rule) 0);
222
+ background-size: 100% var(--_line);
223
+ caret-color: currentColor;
224
+ }
225
+ .post-desk__write::placeholder { color: var(--_muted); }
226
+ .post-desk__corner { display: flex; justify-content: flex-end; }
227
+ .post-desk__lines { display: grid; gap: var(--sp-3); margin-block-start: auto; padding-block-end: var(--sp-6); }
228
+ .post-desk__line { display: grid; gap: var(--sp-0); border-block-end: var(--border-w) solid var(--_rule); }
229
+ .post-desk__line:focus-within { border-block-end-color: currentColor; }
230
+ .post-desk__line > span { font: var(--type-caption); color: var(--_muted); }
231
+ .post-desk__line > input { width: 100%; min-width: 0; min-height: var(--tap); border: 0; outline: none; background: transparent; color: inherit; font: var(--type-title); }
232
+ .post-desk__line > input::placeholder { color: var(--_muted); }
233
+ /* the stamp box: empty it is a dashed place to press; pressed, the chosen stamp lies in it */
234
+ .post-desk__slot { border: 0; background: transparent; color: inherit; }
235
+ @media (prefers-reduced-motion: no-preference) {
236
+ .post-desk__slot > .stamp { animation: post-desk-stick var(--motion-quick) ease-out both; }
237
+ .post-desk .postmark { animation: post-desk-strike var(--motion-press) ease-in both; }
238
+ }
239
+ @keyframes post-desk-stick { from { translate: 0 calc(-1 * var(--sp-8)); rotate: 14deg; opacity: 0; } }
240
+ @keyframes post-desk-strike { from { translate: calc(-1 * var(--sp-3)) calc(-1 * var(--sp-6)); rotate: -24deg; opacity: 0; } }
241
+ /* the other half's showing edge brings it across: a tap-wide strip out to the screen's edge */
242
+ .post-desk__reach { position: absolute; inset-block: 0; width: var(--tap); border: 0; background: transparent; }
243
+ .post-desk__reach--address { inset-inline-end: calc(-1 * var(--screen-pad)); }
244
+ .post-desk__reach--message { inset-inline-start: calc(-1 * var(--screen-pad)); }
245
+ .post-desk__reach:focus-visible { outline: var(--focus); outline-offset: calc(-1 * var(--sp-1)); }
246
+ /* the sender's stamps to choose from, under the card */
247
+ .post-desk__stamps { display: flex; gap: var(--post-desk-stamps-gap, var(--sp-4)); align-items: center; }
248
+ .post-desk__pick {
249
+ display: grid;
250
+ place-items: center;
251
+ min-width: var(--tap);
252
+ min-height: var(--tap);
253
+ padding: var(--sp-1);
254
+ border: 0;
255
+ border-block-end: var(--rule-w) solid transparent;
256
+ background: transparent;
257
+ }
258
+ .post-desk__pick > .stamp { --_w: calc(var(--sp-2) * 9); width: var(--post-desk-pick-w, var(--_w)); max-width: none; rotate: var(--post-corner-stamp-tilt, 2deg); } /* in the tray a stamp keeps its words */
259
+ .post-desk__pick[aria-pressed="true"] { border-block-end-color: var(--on-surface); }
260
+ .post-desk__note { margin: 0; font: var(--type-caption); color: var(--post-desk-note-ink, var(--on-surface-muted)); min-height: 1.35em; }
261
+ /* what stands where the card was, once it has left */
262
+ .post-desk__gone { position: absolute; inset: 0; display: grid; grid-template-columns: minmax(0, 1fr); align-content: center; gap: var(--sp-2); }
263
+ @media (prefers-reduced-motion: reduce) { .post-desk__card { transition: none; } }
264
+
265
+ /* ============================================================
266
+ post-box — KEEPING many. The cards whole and landscape, standing one behind another; each shows its top edge: who, where,
267
+ when, the stamp and the postmark. Pressing an edge parts the box: every card in front slides down and the whole card is
268
+ there to read. The newest stands in front — the foot of the list, nearest the thumb.
269
+ <ul class="post-box">
270
+ <li class="post-box__card post-paper is-open">
271
+ <button class="post-box__edge" aria-expanded="true"><span class="post-box__from">แม่</span><span class="post-box__when">เชียงใหม่ · 3 ม.ค. 69</span></button>
272
+ <div class="post-corner" aria-hidden="true">…</div>
273
+ <div class="post-box__body"><p class="post-box__message">…</p><div class="post-box__to"><span>ถึง ฝน</span>…</div></div>
274
+ </li>…
275
+ </ul>
276
+ ============================================================ */
277
+ /* the card first: its edge is the knob's real reader — the box (below) hands the same length on to the parting */
278
+ .post-box__card {
279
+ position: relative;
280
+ height: var(--_h);
281
+ margin-block-end: calc(var(--_e) - var(--_h));
282
+ display: grid;
283
+ grid-template-rows: var(--post-box-edge, var(--_edge)) minmax(0, 1fr);
284
+ transition: translate var(--motion-sheet) ease, rotate var(--motion-sheet) ease;
285
+ }
286
+ .post-box {
287
+ --_edge: calc(var(--sp-base) * 20);
288
+ --_e: var(--post-box-edge, var(--_edge));
289
+ --_h: calc(100cqi * 2 / 3);
290
+ --_part: calc(var(--_h) - var(--_e) + var(--sp-3));
291
+ container-type: inline-size;
292
+ list-style: none;
293
+ margin: 0;
294
+ padding: 0;
295
+ display: grid;
296
+ }
297
+ .post-box:has(> .post-box__card.is-open:not(:last-child)) { padding-block-end: var(--_part); }
298
+ .post-box__card:last-child { margin-block-end: 0; }
299
+ .post-box__card:nth-child(3n + 1) { rotate: var(--post-box-lean, 0.6deg); }
300
+ .post-box__card:nth-child(3n + 2) { rotate: calc(var(--post-box-lean, 0.6deg) * -0.8); }
301
+ .post-box__card.is-open { rotate: none; }
302
+ .post-box__card.is-open ~ .post-box__card { translate: 0 var(--_part); }
303
+ .post-box__edge {
304
+ display: grid;
305
+ align-content: center;
306
+ justify-items: start;
307
+ gap: var(--sp-0);
308
+ padding: var(--sp-2) calc(var(--tap) * 2.75) var(--sp-1) var(--sp-4);
309
+ border: 0;
310
+ border-radius: var(--post-paper-radius, var(--sp-2));
311
+ background: transparent;
312
+ color: inherit;
313
+ text-align: start;
314
+ }
315
+ .post-box__edge:focus-visible { outline-offset: calc(-1 * var(--sp-1)); }
316
+ .post-box__from { font: var(--type-title); }
317
+ .post-box__when { font: var(--type-caption); color: var(--_muted); white-space: nowrap; }
318
+ .post-box__card > .post-corner { position: absolute; inset-block-start: var(--sp-3); inset-inline-end: var(--sp-4); pointer-events: none; }
319
+ .post-box__body { min-height: 0; display: grid; grid-template-columns: 5fr 4fr; padding: 0 var(--sp-4) var(--sp-3); }
320
+ .post-box__message { margin: 0; padding-inline-end: var(--sp-3); font: var(--type-caption); font-family: var(--font-display); overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 6; line-clamp: 6; align-self: start; }
321
+ .post-box__to { display: grid; align-content: end; gap: var(--sp-1); padding-inline-start: var(--sp-3); border-inline-start: var(--border-w) solid var(--_rule); min-width: 0; }
322
+ .post-box__to > span { padding-block: var(--sp-1); border-block-end: var(--border-w) solid var(--_rule); font: var(--type-caption); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
323
+ /* behind another card only the mark's top shows: the place alone, lifted clear of the next card's edge */
324
+ .post-box__card:not(.is-open) .postmark > span:last-child:not(:first-child) { display: none; }
325
+ .post-box__card:not(.is-open) .postmark { box-sizing: border-box; place-content: start center; padding-block-start: calc(var(--_size) * 0.18); }
326
+ @media (prefers-reduced-motion: reduce) { .post-box__card { transition: none; } }