@usableapp/cardds 0.7.67 → 0.7.69
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 +15 -0
- package/css/stack.css +1 -0
- package/css/stamp.css +6 -2
- package/css/sticky.css +26 -4
- package/dist/cardds.css +33 -6
- package/package.json +1 -1
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.69 — 2026-10-07
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **A `Stamp` in a `Centre` above a closed stack fits a short phone** (reported by timebank, 2026-10-07) — in `Home base={<Centre>…}` / `SheetStack base` the stamp is held by the HEIGHT of the room above the sheet heads as well as by the screen's width: its height is at most 45% of that room, so two lines of words fit under it. Before: sized from the width alone, at 320×568 the stamp ran up under the top bar and the words met the first sheet's head (a group 314px tall in a 253px room). A phone 640 tall or more looks as before. Nothing to change in a screen. Three lines of words under the stamp on a 320×568 phone with three sheets still run a little long: keep the line short.
|
|
9
|
+
|
|
10
|
+
## 0.7.68 — 2026-10-07
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **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.
|
|
19
|
+
|
|
5
20
|
## 0.7.67 — 2026-10-07
|
|
6
21
|
|
|
7
22
|
### Added
|
package/css/stack.css
CHANGED
|
@@ -285,6 +285,7 @@
|
|
|
285
285
|
overflow: hidden;
|
|
286
286
|
overscroll-behavior: none;
|
|
287
287
|
touch-action: none;
|
|
288
|
+
container-type: size; /* what stands in it may be sized by this room (cqh) — its width is the fill screen's own content width, so cqw reads the same as before */
|
|
288
289
|
}
|
|
289
290
|
/* a base that is ONE Page is a page's words (a title, a welcome, the news): it reads from the TOP, like any page —
|
|
290
291
|
loose things (a picture, a line) still sit against the peeks (Lh 2026-09-23, the home) */
|
package/css/stamp.css
CHANGED
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
filter: drop-shadow(var(--stamp-drop, 0 var(--sp-1) var(--sp-2) rgb(0 0 0 / 0.16))); /* drop-shadow follows the teeth; a box-shadow would draw the rectangle */
|
|
32
32
|
}
|
|
33
33
|
.stamp__paper {
|
|
34
|
-
--_pitch:
|
|
35
|
-
--_hole:
|
|
34
|
+
--_pitch: calc(100cqw / 12); /* one tooth: 12 across, 15 down — the holes land whole on every edge. Fewer and larger than a real stamp's: a small stamp's teeth must still read (Lh 2026-10-07; it was 20 × 25) */
|
|
35
|
+
--_hole: 2.6cqw;
|
|
36
36
|
position: absolute;
|
|
37
37
|
inset: 0;
|
|
38
38
|
background-color: var(--stamp-paper-bg, #fffdf8);
|
|
@@ -85,6 +85,9 @@
|
|
|
85
85
|
/* a stamp handed over in a Centre: sized by the SCREEN (a little over half its width inside the inset), its greeting centred under it */
|
|
86
86
|
.screen__centre:has(> .stamp) { justify-items: center; text-align: center; }
|
|
87
87
|
.screen__centre > .stamp { max-width: var(--stamp-centre-max-w, 56cqw); }
|
|
88
|
+
/* in the room above a closed stack's heads the stamp is held by that room's HEIGHT too: its height at most 45% of it, so two lines of words under it
|
|
89
|
+
still fit on a short phone (320×568 left 253px for a group 314 tall — reported by timebank, 2026-10-07). cqh reads the base, a size container. */
|
|
90
|
+
.sheet-stack__base > .screen__centre > .stamp { max-width: min(var(--stamp-centre-max-w, 56cqw), 36cqh); }
|
|
88
91
|
|
|
89
92
|
/* ---- stamp place: an empty place for a stamp — an album's gap, "make yours" ----
|
|
90
93
|
<span class="stamp-place">สร้าง</span> · <button class="stamp-place">…</button> · <span class="stamp-place stamp-place--faint"></span> */
|
|
@@ -176,6 +179,7 @@
|
|
|
176
179
|
pointer-events: auto; /* the layer it lies in lets a tap through to the surface; the stamp itself takes it */
|
|
177
180
|
}
|
|
178
181
|
.stamp-on > .stamp { max-width: none; }
|
|
182
|
+
.stamp-on--move { will-change: transform; } /* its own layer: Safari left the stamp's shadow behind as a trail along the drag when it was painted with the note (seen on the simulator, 2026-10-07) */
|
|
179
183
|
.stamp-on--move { cursor: grab; touch-action: none; -webkit-user-select: none; user-select: none; transition: scale var(--motion-press) ease-out; }
|
|
180
184
|
.stamp-on--move:focus-visible { outline: var(--focus); outline-offset: var(--sp-1); }
|
|
181
185
|
.stamp-on.is-held { cursor: grabbing; z-index: 1; scale: 1.06; } /* lifted off the paper while the finger holds it */
|
package/css/sticky.css
CHANGED
|
@@ -30,8 +30,27 @@
|
|
|
30
30
|
min-height: var(--sticky-min-h, var(--_tall)); /* every note is one height, the height of a note being written (Lh 2026-10-07); more words still grow it */
|
|
31
31
|
padding: 0 var(--_fold) var(--_fold) 0; /* = the line above, written whole */
|
|
32
32
|
background-clip: content-box;
|
|
33
|
-
|
|
33
|
+
isolation: isolate; /* the two layers below stay under this note, over whatever is behind it */
|
|
34
34
|
}
|
|
35
|
+
/* the edge and the shadow: two layers UNDER the paper, each drawn in the note's own shape by a gradient — never a filter on the
|
|
36
|
+
note (Safari repainted a filtered note as a hard grey band once a stamp on it had moved; Lh 2026-10-07).
|
|
37
|
+
::after is the EDGE: the shape one hairline larger all round, so a pale note still ends somewhere on a pale ground.
|
|
38
|
+
::before is the SHADOW: the shape set a little down and in, softened on its own layer — short and close, paper lying on a table. */
|
|
39
|
+
.sticky::before, .sticky::after {
|
|
40
|
+
content: "";
|
|
41
|
+
position: absolute;
|
|
42
|
+
z-index: -1;
|
|
43
|
+
pointer-events: none;
|
|
44
|
+
border-radius: var(--sticky-radius, var(--sp-1));
|
|
45
|
+
background-image: linear-gradient(-45deg, transparent calc(var(--_fold) * 0.7071), var(--_ink) 0); /* the folded corner, cut */
|
|
46
|
+
}
|
|
47
|
+
.sticky::after { --_ink: var(--sticky-edge-bg, rgb(0 0 0 / 0.14)); inset: calc(-1 * var(--border-w)); }
|
|
48
|
+
.sticky::before {
|
|
49
|
+
--_ink: var(--sticky-shadow-bg, rgb(0 0 0 / 0.28));
|
|
50
|
+
inset: var(--sp-1) var(--sp-0) calc(-1 * var(--sp-0)) var(--sp-0);
|
|
51
|
+
filter: blur(var(--sticky-shadow-blur, var(--sp-0)));
|
|
52
|
+
}
|
|
53
|
+
.sticky--flat::before, .sticky--flat::after { background: var(--_ink); }
|
|
35
54
|
/* the tones — the names in src/sticky/tones.ts. A swatch of the chooser wears the same class. */
|
|
36
55
|
.sticky--cotton { background-color: var(--sticky-cotton-bg, #f3efe2); }
|
|
37
56
|
.sticky--blush { background-color: var(--sticky-blush-bg, #f6d5da); }
|
|
@@ -92,8 +111,8 @@
|
|
|
92
111
|
/* --square: the note to write on — 1:1, as wide as its column up to a cap, in the middle */
|
|
93
112
|
.sticky--square { width: 100%; max-width: var(--sticky-square-max-w, var(--_cap)); aspect-ratio: 1; margin-inline: auto; }
|
|
94
113
|
|
|
95
|
-
/* --corner: a control stands ON the bottom-right corner, half outside. The paper is notched round it (a mask, so
|
|
96
|
-
|
|
114
|
+
/* --corner: a control stands ON the bottom-right corner, half outside. The paper is notched round it (a mask, so no
|
|
115
|
+
ground colour is needed; the edge and the shadow are notched the same way); the note has no fold. */
|
|
97
116
|
.sticky__corner {
|
|
98
117
|
position: absolute;
|
|
99
118
|
right: 0;
|
|
@@ -110,6 +129,7 @@
|
|
|
110
129
|
max-width: calc(var(--sticky-max-w, 100%) - var(--_corner) / 2);
|
|
111
130
|
margin: 0 calc(var(--_corner) / 2) calc(var(--_corner) / 2) 0;
|
|
112
131
|
}
|
|
132
|
+
.sticky--corner::before, .sticky--corner::after { background-image: radial-gradient(circle at 100% 100%, transparent calc(var(--_corner) / 2 + var(--sp-1)), var(--_ink) 0); }
|
|
113
133
|
.sticky--corner > .sticky__paper {
|
|
114
134
|
--_notch: calc(var(--_corner) / 2 + var(--sp-1));
|
|
115
135
|
clip-path: none;
|
|
@@ -175,7 +195,7 @@
|
|
|
175
195
|
</div>
|
|
176
196
|
<div class="sticky-post sticky-post--right">…</div>
|
|
177
197
|
</div>
|
|
178
|
-
Two columns: the avatar, then everything else
|
|
198
|
+
Two columns: the avatar, then everything else. --right swaps the columns. */
|
|
179
199
|
.sticky-thread { display: grid; gap: var(--sticky-thread-gap, var(--sp-6)); --_thread-gap: var(--sticky-thread-gap, var(--sp-6)); } /* --_thread-gap: the same value, for the rail to run through */
|
|
180
200
|
.sticky-post {
|
|
181
201
|
display: grid;
|
|
@@ -193,6 +213,8 @@
|
|
|
193
213
|
.sticky-post__notes > .sticky { max-width: var(--sticky-post-max-w, 85%); } /* a note leaves the far side open, as a bubble does */
|
|
194
214
|
.sticky-post__rail { display: none; grid-area: 2 / 1; justify-self: center; align-self: stretch; color: var(--sticky-thread-rail-ink, var(--on-surface-muted)); border-inline-start: var(--rule-w) dotted currentColor; margin-block-end: calc(-1 * var(--_thread-gap)); } /* runs on through the gap to the next avatar */
|
|
195
215
|
.sticky-thread--rail > .sticky-post:not(:last-child) > .sticky-post__rail { display: block; }
|
|
216
|
+
/* a chat (no rail): a note leans on its writer's own edge of the thread — it starts under the avatar, not under the name, so theirs and mine stand apart (Lh 2026-10-07: both sat in the middle). With a rail the notes keep clear of it, under the name. */
|
|
217
|
+
.sticky-thread:not(.sticky-thread--rail) > .sticky-post > .sticky-post__notes { grid-column: 1 / -1; }
|
|
196
218
|
.sticky-post--right { grid-template-columns: minmax(0, 1fr) auto; }
|
|
197
219
|
.sticky-post--right > .sticky-post__who { grid-column: 2; }
|
|
198
220
|
.sticky-post--right > .sticky-post__head { grid-column: 1; flex-direction: row-reverse; }
|
package/dist/cardds.css
CHANGED
|
@@ -1309,6 +1309,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
1309
1309
|
overflow: hidden;
|
|
1310
1310
|
overscroll-behavior: none;
|
|
1311
1311
|
touch-action: none;
|
|
1312
|
+
container-type: size; /* what stands in it may be sized by this room (cqh) — its width is the fill screen's own content width, so cqw reads the same as before */
|
|
1312
1313
|
}
|
|
1313
1314
|
/* a base that is ONE Page is a page's words (a title, a welcome, the news): it reads from the TOP, like any page —
|
|
1314
1315
|
loose things (a picture, a line) still sit against the peeks (Lh 2026-09-23, the home) */
|
|
@@ -3659,8 +3660,27 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
3659
3660
|
min-height: var(--sticky-min-h, var(--_tall)); /* every note is one height, the height of a note being written (Lh 2026-10-07); more words still grow it */
|
|
3660
3661
|
padding: 0 var(--_fold) var(--_fold) 0; /* = the line above, written whole */
|
|
3661
3662
|
background-clip: content-box;
|
|
3662
|
-
|
|
3663
|
+
isolation: isolate; /* the two layers below stay under this note, over whatever is behind it */
|
|
3663
3664
|
}
|
|
3665
|
+
/* the edge and the shadow: two layers UNDER the paper, each drawn in the note's own shape by a gradient — never a filter on the
|
|
3666
|
+
note (Safari repainted a filtered note as a hard grey band once a stamp on it had moved; Lh 2026-10-07).
|
|
3667
|
+
::after is the EDGE: the shape one hairline larger all round, so a pale note still ends somewhere on a pale ground.
|
|
3668
|
+
::before is the SHADOW: the shape set a little down and in, softened on its own layer — short and close, paper lying on a table. */
|
|
3669
|
+
.sticky::before, .sticky::after {
|
|
3670
|
+
content: "";
|
|
3671
|
+
position: absolute;
|
|
3672
|
+
z-index: -1;
|
|
3673
|
+
pointer-events: none;
|
|
3674
|
+
border-radius: var(--sticky-radius, var(--sp-1));
|
|
3675
|
+
background-image: linear-gradient(-45deg, transparent calc(var(--_fold) * 0.7071), var(--_ink) 0); /* the folded corner, cut */
|
|
3676
|
+
}
|
|
3677
|
+
.sticky::after { --_ink: var(--sticky-edge-bg, rgb(0 0 0 / 0.14)); inset: calc(-1 * var(--border-w)); }
|
|
3678
|
+
.sticky::before {
|
|
3679
|
+
--_ink: var(--sticky-shadow-bg, rgb(0 0 0 / 0.28));
|
|
3680
|
+
inset: var(--sp-1) var(--sp-0) calc(-1 * var(--sp-0)) var(--sp-0);
|
|
3681
|
+
filter: blur(var(--sticky-shadow-blur, var(--sp-0)));
|
|
3682
|
+
}
|
|
3683
|
+
.sticky--flat::before, .sticky--flat::after { background: var(--_ink); }
|
|
3664
3684
|
/* the tones — the names in src/sticky/tones.ts. A swatch of the chooser wears the same class. */
|
|
3665
3685
|
.sticky--cotton { background-color: var(--sticky-cotton-bg, #f3efe2); }
|
|
3666
3686
|
.sticky--blush { background-color: var(--sticky-blush-bg, #f6d5da); }
|
|
@@ -3721,8 +3741,8 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
3721
3741
|
/* --square: the note to write on — 1:1, as wide as its column up to a cap, in the middle */
|
|
3722
3742
|
.sticky--square { width: 100%; max-width: var(--sticky-square-max-w, var(--_cap)); aspect-ratio: 1; margin-inline: auto; }
|
|
3723
3743
|
|
|
3724
|
-
/* --corner: a control stands ON the bottom-right corner, half outside. The paper is notched round it (a mask, so
|
|
3725
|
-
|
|
3744
|
+
/* --corner: a control stands ON the bottom-right corner, half outside. The paper is notched round it (a mask, so no
|
|
3745
|
+
ground colour is needed; the edge and the shadow are notched the same way); the note has no fold. */
|
|
3726
3746
|
.sticky__corner {
|
|
3727
3747
|
position: absolute;
|
|
3728
3748
|
right: 0;
|
|
@@ -3739,6 +3759,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
3739
3759
|
max-width: calc(var(--sticky-max-w, 100%) - var(--_corner) / 2);
|
|
3740
3760
|
margin: 0 calc(var(--_corner) / 2) calc(var(--_corner) / 2) 0;
|
|
3741
3761
|
}
|
|
3762
|
+
.sticky--corner::before, .sticky--corner::after { background-image: radial-gradient(circle at 100% 100%, transparent calc(var(--_corner) / 2 + var(--sp-1)), var(--_ink) 0); }
|
|
3742
3763
|
.sticky--corner > .sticky__paper {
|
|
3743
3764
|
--_notch: calc(var(--_corner) / 2 + var(--sp-1));
|
|
3744
3765
|
clip-path: none;
|
|
@@ -3804,7 +3825,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
3804
3825
|
</div>
|
|
3805
3826
|
<div class="sticky-post sticky-post--right">…</div>
|
|
3806
3827
|
</div>
|
|
3807
|
-
Two columns: the avatar, then everything else
|
|
3828
|
+
Two columns: the avatar, then everything else. --right swaps the columns. */
|
|
3808
3829
|
.sticky-thread { display: grid; gap: var(--sticky-thread-gap, var(--sp-6)); --_thread-gap: var(--sticky-thread-gap, var(--sp-6)); } /* --_thread-gap: the same value, for the rail to run through */
|
|
3809
3830
|
.sticky-post {
|
|
3810
3831
|
display: grid;
|
|
@@ -3822,6 +3843,8 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
3822
3843
|
.sticky-post__notes > .sticky { max-width: var(--sticky-post-max-w, 85%); } /* a note leaves the far side open, as a bubble does */
|
|
3823
3844
|
.sticky-post__rail { display: none; grid-area: 2 / 1; justify-self: center; align-self: stretch; color: var(--sticky-thread-rail-ink, var(--on-surface-muted)); border-inline-start: var(--rule-w) dotted currentColor; margin-block-end: calc(-1 * var(--_thread-gap)); } /* runs on through the gap to the next avatar */
|
|
3824
3845
|
.sticky-thread--rail > .sticky-post:not(:last-child) > .sticky-post__rail { display: block; }
|
|
3846
|
+
/* a chat (no rail): a note leans on its writer's own edge of the thread — it starts under the avatar, not under the name, so theirs and mine stand apart (Lh 2026-10-07: both sat in the middle). With a rail the notes keep clear of it, under the name. */
|
|
3847
|
+
.sticky-thread:not(.sticky-thread--rail) > .sticky-post > .sticky-post__notes { grid-column: 1 / -1; }
|
|
3825
3848
|
.sticky-post--right { grid-template-columns: minmax(0, 1fr) auto; }
|
|
3826
3849
|
.sticky-post--right > .sticky-post__who { grid-column: 2; }
|
|
3827
3850
|
.sticky-post--right > .sticky-post__head { grid-column: 1; flex-direction: row-reverse; }
|
|
@@ -3863,8 +3886,8 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
3863
3886
|
filter: drop-shadow(var(--stamp-drop, 0 var(--sp-1) var(--sp-2) rgb(0 0 0 / 0.16))); /* drop-shadow follows the teeth; a box-shadow would draw the rectangle */
|
|
3864
3887
|
}
|
|
3865
3888
|
.stamp__paper {
|
|
3866
|
-
--_pitch:
|
|
3867
|
-
--_hole:
|
|
3889
|
+
--_pitch: calc(100cqw / 12); /* one tooth: 12 across, 15 down — the holes land whole on every edge. Fewer and larger than a real stamp's: a small stamp's teeth must still read (Lh 2026-10-07; it was 20 × 25) */
|
|
3890
|
+
--_hole: 2.6cqw;
|
|
3868
3891
|
position: absolute;
|
|
3869
3892
|
inset: 0;
|
|
3870
3893
|
background-color: var(--stamp-paper-bg, #fffdf8);
|
|
@@ -3917,6 +3940,9 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
3917
3940
|
/* a stamp handed over in a Centre: sized by the SCREEN (a little over half its width inside the inset), its greeting centred under it */
|
|
3918
3941
|
.screen__centre:has(> .stamp) { justify-items: center; text-align: center; }
|
|
3919
3942
|
.screen__centre > .stamp { max-width: var(--stamp-centre-max-w, 56cqw); }
|
|
3943
|
+
/* in the room above a closed stack's heads the stamp is held by that room's HEIGHT too: its height at most 45% of it, so two lines of words under it
|
|
3944
|
+
still fit on a short phone (320×568 left 253px for a group 314 tall — reported by timebank, 2026-10-07). cqh reads the base, a size container. */
|
|
3945
|
+
.sheet-stack__base > .screen__centre > .stamp { max-width: min(var(--stamp-centre-max-w, 56cqw), 36cqh); }
|
|
3920
3946
|
|
|
3921
3947
|
/* ---- stamp place: an empty place for a stamp — an album's gap, "make yours" ----
|
|
3922
3948
|
<span class="stamp-place">สร้าง</span> · <button class="stamp-place">…</button> · <span class="stamp-place stamp-place--faint"></span> */
|
|
@@ -4008,6 +4034,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
4008
4034
|
pointer-events: auto; /* the layer it lies in lets a tap through to the surface; the stamp itself takes it */
|
|
4009
4035
|
}
|
|
4010
4036
|
.stamp-on > .stamp { max-width: none; }
|
|
4037
|
+
.stamp-on--move { will-change: transform; } /* its own layer: Safari left the stamp's shadow behind as a trail along the drag when it was painted with the note (seen on the simulator, 2026-10-07) */
|
|
4011
4038
|
.stamp-on--move { cursor: grab; touch-action: none; -webkit-user-select: none; user-select: none; transition: scale var(--motion-press) ease-out; }
|
|
4012
4039
|
.stamp-on--move:focus-visible { outline: var(--focus); outline-offset: var(--sp-1); }
|
|
4013
4040
|
.stamp-on.is-held { cursor: grabbing; z-index: 1; scale: 1.06; } /* lifted off the paper while the finger holds it */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usableapp/cardds",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.69",
|
|
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.",
|