@junoput01/junoui 0.6.0 → 0.8.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/CHANGELOG.md +209 -0
- package/README.md +17 -16
- package/dist/android/dimens.xml +2 -0
- package/dist/classes.json +1774 -0
- package/dist/css/juno-tokens.css +10 -0
- package/dist/css/juno.css +1057 -56
- package/dist/flutter/juno_tokens.dart +10 -0
- package/dist/ios/JunoTokens.swift +10 -0
- package/dist/js/tokens.js +16 -0
- package/dist/json/tokens.json +51 -0
- package/dist/rust/juno_tokens.rs +383 -0
- package/dist/scss/_juno-tokens.scss +10 -0
- package/docs/accessibility.md +6 -0
- package/docs/browser-support.md +3 -0
- package/docs/components/button.md +11 -2
- package/docs/components/canvas-ink.md +71 -0
- package/docs/components/dock.md +34 -0
- package/docs/components/fold-slot.md +26 -1
- package/docs/components/gizmo.md +114 -0
- package/docs/components/swatch.md +95 -0
- package/docs/components/tree.md +112 -0
- package/docs/conformance-kit.md +255 -0
- package/docs/getting-started.md +14 -0
- package/docs/integration.md +52 -6
- package/docs/ios-conformance.md +200 -3
- package/docs/ios-pwa.md +273 -0
- package/docs/native.md +38 -1
- package/docs/tokens-reference.md +15 -0
- package/package.json +8 -2
- package/src/css/base.css +35 -43
- package/src/css/components/button.css +42 -2
- package/src/css/components/canvas-ink.css +97 -0
- package/src/css/components/dock.css +65 -6
- package/src/css/components/fold-slot.css +49 -3
- package/src/css/components/gizmo.css +238 -0
- package/src/css/components/segmented.css +15 -2
- package/src/css/components/swatch.css +187 -0
- package/src/css/components/tree.css +259 -0
- package/src/css/touch-surfaces.mjs +95 -0
- package/tools/gizmo.mjs +144 -0
- package/tools/testing.mjs +177 -0
- package/tools/tree.mjs +178 -0
package/docs/components/dock.md
CHANGED
|
@@ -187,6 +187,40 @@ over scroll-event discretization — anything longer visibly lags the finger.
|
|
|
187
187
|
plus the pill's padding and border), `--juno-dock-edge-gap` (what the
|
|
188
188
|
floating margins take from `100%` — default `2 × space.12`).
|
|
189
189
|
|
|
190
|
+
## How many items fit
|
|
191
|
+
|
|
192
|
+
`.juno-dock__item` is `flex: 1 1 0`, so the bar divides its inner width by
|
|
193
|
+
however many items are present. A consumer deciding **how many to render** — and
|
|
194
|
+
whether they still hold a tap target — reads that budget from junoui rather than
|
|
195
|
+
re-deriving it from the numbers in `dock.css`:
|
|
196
|
+
|
|
197
|
+
| Custom property | What it is |
|
|
198
|
+
| --------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
199
|
+
| `--juno-dock-items` | The item budget. **You set it** to what you render; junoui does not enforce it, it derives from it. |
|
|
200
|
+
| `--juno-dock-item-inline` | The width one item gets. A prediction of what the flex layout produces — asserted against the measured box in CI. |
|
|
201
|
+
| `--juno-dock-fit-inline` | The narrowest viewport at which every item still holds `--juno-size-tap-comfortable`. Below it, drop an item. |
|
|
202
|
+
| `--juno-dock-chrome-inline` | The bar's total inline chrome (margin + padding + border, both sides). `0` on the full-bleed bar, `34px` on the pill. |
|
|
203
|
+
| `--juno-dock-avail` | The width the budget divides. Defaults to `100vw`; override it when the bar is not viewport-wide. |
|
|
204
|
+
|
|
205
|
+
```css
|
|
206
|
+
/* five destinations need 254px; below that, render four */
|
|
207
|
+
@media (max-width: 253px) {
|
|
208
|
+
.my-dock__item--secondary {
|
|
209
|
+
display: none;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The margin, padding and border terms are declared **once** and consumed by both
|
|
215
|
+
the variant's own box and the sum above, so the budget cannot disagree with the
|
|
216
|
+
bar it describes — the same construction as `--juno-dock-edge-offset`.
|
|
217
|
+
|
|
218
|
+
**There is deliberately no scale floor.** "What scale keeps a 44px target?" is
|
|
219
|
+
`44px / --juno-dock-item-inline`, a ratio of two lengths, and CSS cannot divide
|
|
220
|
+
by a length. A consumer that must scale rather than drop compares those two
|
|
221
|
+
values itself. Prefer dropping an item: scaling a bar scales its hit areas with
|
|
222
|
+
it, which is the problem the floor was being computed to avoid.
|
|
223
|
+
|
|
190
224
|
## Anatomy (any platform)
|
|
191
225
|
|
|
192
226
|
- Full-width bar on `s1`, hairline seam on the block-start edge; items split the
|
|
@@ -10,7 +10,7 @@ coming in, end-of-fade going out). Keep the element mounted; flip
|
|
|
10
10
|
|
|
11
11
|
```html
|
|
12
12
|
<div class="juno-pillbar">
|
|
13
|
-
<button class="juno-fold" data-juno-in aria-label="Scroll to top">
|
|
13
|
+
<button class="juno-fold juno-pillbar__item" data-juno-in aria-label="Scroll to top">
|
|
14
14
|
<svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-arrow-up" /></svg>
|
|
15
15
|
</button>
|
|
16
16
|
…other members…
|
|
@@ -24,6 +24,31 @@ coming in, end-of-fade going out). Keep the element mounted; flip
|
|
|
24
24
|
| `--juno-fold-size` | The slot's open width (default `size.tap.comfortable`) — must be definite, transitions cannot run to an intrinsic size. |
|
|
25
25
|
| `--juno-fold-gap` | The row's flex/grid gap (default `0px`) — the folded slot swallows one gap with a negative margin so the row closes completely. |
|
|
26
26
|
|
|
27
|
+
## Composing it with a component class
|
|
28
|
+
|
|
29
|
+
The canonical use puts `.juno-fold` on an element that already carries a
|
|
30
|
+
component class — `.juno-pillbar__item`, `.juno-btn`, `.juno-chip` — because that
|
|
31
|
+
is where the capsule chrome (tap target, padding, hover, focus ring,
|
|
32
|
+
`aria-pressed`) lives. The fold is built for that, and two things about it are
|
|
33
|
+
worth knowing rather than rediscovering:
|
|
34
|
+
|
|
35
|
+
**Which floors it releases.** A border-box inline size has exactly three inputs
|
|
36
|
+
that can hold it above zero, and the folded state releases all three:
|
|
37
|
+
`min-inline-size` (a component's tap floor), `padding-inline`, and
|
|
38
|
+
`border-inline-width`. Each is in the fold's transition list too, so the content
|
|
39
|
+
neither snaps sideways as the fold starts nor jumps to full width when the slot
|
|
40
|
+
opens. Composed with `.juno-pillbar__item` and none of them released, the folded
|
|
41
|
+
slot lays out at 44px — the tap target — and the row never closes.
|
|
42
|
+
|
|
43
|
+
**It takes over the element's `transition`.** `transition` is a shorthand: two
|
|
44
|
+
rules setting it do not merge, the winner replaces the loser's whole list. The
|
|
45
|
+
fold's declarations are stated at attribute specificity so it wins that, and its
|
|
46
|
+
list therefore also carries the chrome properties (`color`, `background-color`)
|
|
47
|
+
the components animate — composing costs the capsule nothing. A component of your
|
|
48
|
+
own that composes with `.juno-fold` and needs a third property transitioned
|
|
49
|
+
should state it at higher specificity than `.juno-fold[data-juno-in]` (0,2,0),
|
|
50
|
+
repeating the fold's own entries, or the fold's list wins and yours is dropped.
|
|
51
|
+
|
|
27
52
|
- The row's own width follows the slot, so the whole row slides open/closed.
|
|
28
53
|
- `prefers-reduced-motion`: handled by the base layer (states still apply,
|
|
29
54
|
transition durations collapse).
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Viewport orientation gizmo
|
|
2
|
+
|
|
3
|
+
The orientation widget any 3D or map viewport ships: a compass ring with
|
|
4
|
+
clickable snap targets, a secondary arc for a second angle (pitch, tilt,
|
|
5
|
+
elevation) inside a clamped range, and a centre target that resets the view.
|
|
6
|
+
|
|
7
|
+
CAD and BIM viewers, product configurators, virtual tours, model previews, floor
|
|
8
|
+
plans, any map with a tilt.
|
|
9
|
+
|
|
10
|
+
## A ring, not a cube
|
|
11
|
+
|
|
12
|
+
An Autodesk-style ViewCube is the wrong shape for anything with a **privileged
|
|
13
|
+
up-vector** — a map, a terrain, a site plan — because there is no meaningful
|
|
14
|
+
front, right or bottom face to click. A ring degrades to that case and
|
|
15
|
+
generalises to free orbit; a cube does not go the other way.
|
|
16
|
+
|
|
17
|
+
## Web
|
|
18
|
+
|
|
19
|
+
```html
|
|
20
|
+
<div
|
|
21
|
+
class="juno-gizmo"
|
|
22
|
+
role="group"
|
|
23
|
+
aria-label="View orientation"
|
|
24
|
+
style="--juno-gizmo-heading:45deg; --juno-gizmo-pitch:35deg;"
|
|
25
|
+
>
|
|
26
|
+
<p class="juno-gizmo__readout" aria-live="polite">
|
|
27
|
+
Facing north-east, 45 degrees. Tilted 35 degrees.
|
|
28
|
+
</p>
|
|
29
|
+
<div class="juno-gizmo__ring">
|
|
30
|
+
<span class="juno-gizmo__needle" aria-hidden="true"></span>
|
|
31
|
+
<button class="juno-gizmo__mark" style="--juno-gizmo-at:0deg" aria-label="Face north">N</button>
|
|
32
|
+
<!-- …seven more, every 45° -->
|
|
33
|
+
<button class="juno-gizmo__center" aria-label="Reset view to north, level">⌖</button>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="juno-gizmo__arc"><span class="juno-gizmo__arc-hand" aria-hidden="true"></span></div>
|
|
36
|
+
</div>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Class / prop | Effect |
|
|
40
|
+
| --------------------------------- | -------------------------------------------- |
|
|
41
|
+
| `.juno-gizmo` | Root. Holds the angles and the derived size. |
|
|
42
|
+
| `.juno-gizmo__readout` | The spoken state. `aria-live="polite"`. |
|
|
43
|
+
| `.juno-gizmo__ring` | The compass ring. |
|
|
44
|
+
| `.juno-gizmo__needle` | Points at `--juno-gizmo-heading`. |
|
|
45
|
+
| `.juno-gizmo__mark` | A snap target. A real `<button>`. |
|
|
46
|
+
| `.juno-gizmo__center` | Reset target. |
|
|
47
|
+
| `.juno-gizmo__arc` / `__arc-hand` | The second angle, clamped. |
|
|
48
|
+
| `--juno-gizmo-heading` / `-pitch` | **The app writes these.** |
|
|
49
|
+
| `--juno-gizmo-at` | One mark's own bearing. |
|
|
50
|
+
| `--juno-gizmo-pitch-min` / `-max` | The clamp (default `0deg`–`85deg`). |
|
|
51
|
+
| `--juno-gizmo-size` | Ring diameter. Derived — see below. |
|
|
52
|
+
|
|
53
|
+
**The app owns the camera.** junoui rotates the needle and the hand; it never
|
|
54
|
+
stores or changes an angle.
|
|
55
|
+
|
|
56
|
+
## The accessibility contract
|
|
57
|
+
|
|
58
|
+
This is why the component is upstream rather than in each app.
|
|
59
|
+
|
|
60
|
+
- **`role="group"`** with an accessible name on the root.
|
|
61
|
+
- **Snap targets are real `<button>`s.** Not a canvas hit test, not a `div` with
|
|
62
|
+
a click handler. A button is focusable, activates on Enter _and_ Space, is
|
|
63
|
+
announced as a control, and works in a screen reader's forms mode.
|
|
64
|
+
- **Every mark carries an accessible name**, because `"N"` is a letter, not a
|
|
65
|
+
name. `aria-label="Face north"`.
|
|
66
|
+
- **One focus stop.** Tab reaches the gizmo once; arrow keys move between marks
|
|
67
|
+
and **wrap**, Enter activates. Eight tab stops for eight compass points is what
|
|
68
|
+
apps ship and what makes the widget unusable by keyboard.
|
|
69
|
+
- **The bearing is announced in words.** A rotating needle announces nothing, and
|
|
70
|
+
`"37deg"` is a number the listener has to convert. The readout is a live region
|
|
71
|
+
saying _"Facing north-east, 37 degrees. Tilted 45 degrees."_
|
|
72
|
+
- **`aria-current="true"`** on the mark the camera is nearest — not a class, since
|
|
73
|
+
the app must say it for the screen reader anyway.
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
import { enhanceGizmo, orientationLabel, bearingLabel } from 'junoui/gizmo';
|
|
77
|
+
|
|
78
|
+
enhanceGizmo(el); // one focus stop, wrapping arrows
|
|
79
|
+
readout.textContent = orientationLabel(yaw, pitch);
|
|
80
|
+
bearingLabel(37); // → "north-east"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Stateless, like `junoui/tree`: it moves focus and lets the marks' own click
|
|
84
|
+
handlers fire. It never writes an angle.
|
|
85
|
+
|
|
86
|
+
## The diameter is derived, not chosen
|
|
87
|
+
|
|
88
|
+
`N` marks sit evenly around the rim, and each is inset from it by half a target,
|
|
89
|
+
so their centres lie on a circle of radius `d/2 − tap/2`. The straight-line
|
|
90
|
+
distance between two adjacent centres — the **chord**, not the arc — must be at
|
|
91
|
+
least one tap target:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
d ≥ tap · (1 / sin(π / N) + 1)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
which is what `--juno-gizmo-size` computes: `158.98px` at `N = 8` and a 44px
|
|
98
|
+
target.
|
|
99
|
+
|
|
100
|
+
**This derivation was wrong twice, and both errors were invisible in the
|
|
101
|
+
source.** Sizing off the _arc_ between centres (`N · tap / π`) gives 112.05px,
|
|
102
|
+
whose chord is 42.9px — every neighbouring pair overlapping by 1.1px. Correcting
|
|
103
|
+
to the chord but forgetting the inset gives 114.98px and measures 27.16px between
|
|
104
|
+
centres. Both were caught by measuring a laid-out ring, which is why
|
|
105
|
+
`test/visual/gizmo.spec.mjs` asserts the distance between every pair of marks
|
|
106
|
+
rather than trusting the formula. The tap floor **moves** — 24px on a
|
|
107
|
+
fine pointer, 44px on a coarse one — so a ring with a hard-coded diameter has
|
|
108
|
+
eight overlapping targets on a phone. Setting `--juno-gizmo-size` larger is fine;
|
|
109
|
+
`max()` stops it going below what the marks need.
|
|
110
|
+
|
|
111
|
+
## Motion
|
|
112
|
+
|
|
113
|
+
Snap transitions run on `--juno-motion-scale`, so `prefers-reduced-motion`
|
|
114
|
+
collapses them through the base layer without a component-local media query.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Colour swatch & palette
|
|
2
|
+
|
|
3
|
+
Showing a user-chosen colour, and letting someone pick one. Diagrams, calendars,
|
|
4
|
+
tag and label systems, chart series colours, annotation tools, theming UIs,
|
|
5
|
+
kanban boards.
|
|
6
|
+
|
|
7
|
+
## The hard part is not the square
|
|
8
|
+
|
|
9
|
+
A swatch shows an **arbitrary** colour, so every piece of chrome on it — its
|
|
10
|
+
border, its focus ring, its checked indicator — has to stay visible against a
|
|
11
|
+
colour junoui has never seen. A single hairline fails at one end of the range: a
|
|
12
|
+
dark border vanishes on near-black, a light one vanishes on near-white, and the
|
|
13
|
+
swatch that loses its border is the one that has merged with the panel behind it.
|
|
14
|
+
|
|
15
|
+
So the border is a **pair** of hairlines, one dark (inset) and one light
|
|
16
|
+
(outset). Whatever the swatch, one of them contrasts; the other is the one you
|
|
17
|
+
do not notice. `test/swatch.test.mjs` sweeps the swatch colour and asserts one
|
|
18
|
+
ring always clears 3:1, rather than asserting a border value.
|
|
19
|
+
|
|
20
|
+
**Focus rings sit outside the swatch, with an offset**, so their contrast is
|
|
21
|
+
against the panel — a known surface — rather than against a hue junoui cannot
|
|
22
|
+
predict. A ring drawn _on_ the swatch has the same unsolvable problem, and a
|
|
23
|
+
thicker ring does not fix a hue collision.
|
|
24
|
+
|
|
25
|
+
## Web
|
|
26
|
+
|
|
27
|
+
```html
|
|
28
|
+
<span
|
|
29
|
+
class="juno-swatch"
|
|
30
|
+
style="--juno-swatch-color:#C41E3A"
|
|
31
|
+
role="img"
|
|
32
|
+
aria-label="Crimson"
|
|
33
|
+
></span>
|
|
34
|
+
|
|
35
|
+
<button
|
|
36
|
+
class="juno-swatch juno-swatch--button"
|
|
37
|
+
style="--juno-swatch-color:#1F6FEB"
|
|
38
|
+
aria-label="Annotation colour: Azure"
|
|
39
|
+
popovertarget="palette"
|
|
40
|
+
></button>
|
|
41
|
+
|
|
42
|
+
<div class="juno-popover" popover id="palette">
|
|
43
|
+
<div class="juno-palette" role="listbox" aria-label="Annotation colour">
|
|
44
|
+
<button
|
|
45
|
+
class="juno-palette__option"
|
|
46
|
+
role="option"
|
|
47
|
+
aria-selected="true"
|
|
48
|
+
style="--juno-swatch-color:#1F6FEB"
|
|
49
|
+
aria-label="Azure"
|
|
50
|
+
>
|
|
51
|
+
<svg class="juno-icon juno-palette__check" aria-hidden="true">
|
|
52
|
+
<use href="…#juno-i-check" />
|
|
53
|
+
</svg>
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
| Class / prop | Effect |
|
|
60
|
+
| ----------------------------- | ------------------------------------------------------- |
|
|
61
|
+
| `.juno-swatch` | The square. Two-tone ring, sized off the control scale. |
|
|
62
|
+
| `.juno-swatch--circle` | Round rather than square. |
|
|
63
|
+
| `.juno-swatch--sm` | `space.16` — inline with body text. |
|
|
64
|
+
| `.juno-swatch--lg` | `size.tap.comfortable` — a primary trigger. |
|
|
65
|
+
| `.juno-swatch--button` | Swatch used as a trigger. |
|
|
66
|
+
| `.juno-swatch--none` | "No colour" — a slash, not a grey. |
|
|
67
|
+
| `.juno-palette__option--none` | The same, as a choice in the grid. |
|
|
68
|
+
| `.juno-palette` | The grid inside a `.juno-popover`. |
|
|
69
|
+
| `.juno-palette__option` | One choice. Selected shows a glyph **and** a ring. |
|
|
70
|
+
| `--juno-swatch-color` | **The app writes this.** |
|
|
71
|
+
| `--juno-swatch-size` | Defaults to `size.tap.min`. |
|
|
72
|
+
| `--juno-palette-columns` | Grid width (default `6`). |
|
|
73
|
+
|
|
74
|
+
The app owns the colour list and which one is chosen.
|
|
75
|
+
|
|
76
|
+
## Colour is never the only signal
|
|
77
|
+
|
|
78
|
+
junoui's standing rule, and a bare swatch is exactly what violates it.
|
|
79
|
+
|
|
80
|
+
- **Every swatch carries an accessible name.** `aria-label="Crimson"`, not a bare
|
|
81
|
+
square. A decorative swatch beside its own visible label can be `aria-hidden`,
|
|
82
|
+
but a swatch that _is_ the information needs the name.
|
|
83
|
+
- **The checked state is a glyph**, not a hue. "The chosen one looks slightly
|
|
84
|
+
different" is invisible to anyone who cannot separate the two hues — and to
|
|
85
|
+
anyone reading a screenshot. The selected option also grows a ring in the
|
|
86
|
+
active role, so there are two non-colour cues.
|
|
87
|
+
- **The check itself sits on an arbitrary colour**, so it gets a light glyph with
|
|
88
|
+
a dark halo — the canvas-ink pair at glyph scale.
|
|
89
|
+
- **"No colour" is a slash, not a grey.** A consumer without this paints unset as
|
|
90
|
+
a mid grey and the user cannot tell _grey_ from _none_, which is a different
|
|
91
|
+
thing to know.
|
|
92
|
+
|
|
93
|
+
`role="listbox"` + `role="option"` + `aria-selected` is the contract for a
|
|
94
|
+
single-choice palette. The popover, its trigger and the open/close behaviour are
|
|
95
|
+
`.juno-popover`'s — this is the grid that goes inside it.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Tree / outliner
|
|
2
|
+
|
|
3
|
+
Nested rows at arbitrary depth with disclosure, selection and a reorder handle.
|
|
4
|
+
Layer stacks, file browsers, settings trees, org charts, comment threads, nested
|
|
5
|
+
navigation. `.juno-list` is flat and `.juno-accordion` is single-level; this
|
|
6
|
+
nests.
|
|
7
|
+
|
|
8
|
+
## Web
|
|
9
|
+
|
|
10
|
+
```html
|
|
11
|
+
<ul class="juno-tree" role="tree" aria-label="Scene">
|
|
12
|
+
<li
|
|
13
|
+
class="juno-tree__item"
|
|
14
|
+
role="treeitem"
|
|
15
|
+
aria-level="1"
|
|
16
|
+
aria-expanded="true"
|
|
17
|
+
aria-selected="false"
|
|
18
|
+
>
|
|
19
|
+
<div class="juno-tree__row">
|
|
20
|
+
<button class="juno-tree__caret" tabindex="-1" aria-hidden="true"></button>
|
|
21
|
+
<svg class="juno-icon juno-tree__icon" aria-hidden="true"><use href="…#juno-i-stack" /></svg>
|
|
22
|
+
<span class="juno-tree__label">Basemap</span>
|
|
23
|
+
<span class="juno-tree__count">3</span>
|
|
24
|
+
<span class="juno-tree__trail"><span class="juno-badge juno--nominal">ON</span></span>
|
|
25
|
+
<button class="juno-tree__handle" aria-label="Reorder Basemap"></button>
|
|
26
|
+
</div>
|
|
27
|
+
<ul class="juno-tree__group" role="group">
|
|
28
|
+
<li class="juno-tree__item" role="treeitem" aria-level="2" aria-selected="true">…</li>
|
|
29
|
+
</ul>
|
|
30
|
+
</li>
|
|
31
|
+
</ul>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Class / prop | Effect |
|
|
35
|
+
| -------------------- | ------------------------------------------------------------------------ |
|
|
36
|
+
| `.juno-tree` | The `role="tree"` root. Sets `--juno-tree-indent`. |
|
|
37
|
+
| `.juno-tree__item` | One `role="treeitem"`. Carries `aria-expanded` / `-level` / `-selected`. |
|
|
38
|
+
| `.juno-tree__group` | A nested `role="group"`. One indent step; depth is structural. |
|
|
39
|
+
| `.juno-tree__row` | The focusable line. Holds the tap floor. |
|
|
40
|
+
| `.juno-tree__caret` | Disclosure triangle; rotates on `aria-expanded="true"`. |
|
|
41
|
+
| `.juno-tree__count` | Count badge slot on a group row. |
|
|
42
|
+
| `.juno-tree__trail` | Trailing-control slot, as on `.juno-list__row`. |
|
|
43
|
+
| `.juno-tree__handle` | Reorder affordance and its hit area. |
|
|
44
|
+
| `--juno-tree-indent` | One indent step (default `space.16`). |
|
|
45
|
+
|
|
46
|
+
Expansion and collapse are `aria-expanded` on the item — the app owns it, as with
|
|
47
|
+
`aria-pressed` elsewhere in junoui. No JS is needed for the visuals.
|
|
48
|
+
|
|
49
|
+
## The ARIA contract
|
|
50
|
+
|
|
51
|
+
This is the half apps get wrong, so it is stated rather than implied.
|
|
52
|
+
|
|
53
|
+
- **`role="tree"`** on the root, with an accessible name (`aria-label` or
|
|
54
|
+
`aria-labelledby`). **`role="group"`** on every nested list, **`role="treeitem"`**
|
|
55
|
+
on every item.
|
|
56
|
+
- **`aria-level`** on every item, 1-based. Required: the nesting is visual, and
|
|
57
|
+
a screen reader does not infer depth from indentation.
|
|
58
|
+
- **`aria-expanded`** on branches only. **Its absence is what makes an item a
|
|
59
|
+
leaf** — do not put `aria-expanded="false"` on a childless row, or it is
|
|
60
|
+
announced as a collapsed branch that never opens.
|
|
61
|
+
- **`aria-selected`** for what the next action applies to. Distinct from
|
|
62
|
+
`aria-current` (which node you are _on_) and from `:hover`. A layer stack has
|
|
63
|
+
all three at once, which is why they paint differently.
|
|
64
|
+
- **Roving tabindex**: exactly one row has `tabindex="0"`, the rest `-1`. One Tab
|
|
65
|
+
stop for the whole tree, not one per node.
|
|
66
|
+
- **Multi-select** is `aria-multiselectable="true"` on the root plus
|
|
67
|
+
`aria-selected` on each item.
|
|
68
|
+
|
|
69
|
+
### Keyboard
|
|
70
|
+
|
|
71
|
+
| Key | Action |
|
|
72
|
+
| ------------ | --------------------------------------------------------------- |
|
|
73
|
+
| ↓ / ↑ | Next / previous **visible** item, across levels |
|
|
74
|
+
| → | Closed branch: expand. Open branch: first child. Leaf: nothing. |
|
|
75
|
+
| ← | Open branch: collapse. Otherwise: parent. |
|
|
76
|
+
| Home / End | First / last visible item |
|
|
77
|
+
| Enter, Space | Select |
|
|
78
|
+
| `*` | Expand every sibling at this level |
|
|
79
|
+
|
|
80
|
+
A tree without this is a list of buttons wearing tree roles. Because it is
|
|
81
|
+
behaviour, no stylesheet can ship it — so junoui ships a stateless enhancer:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
import { enhanceTree } from 'junoui/tree';
|
|
85
|
+
const stop = enhanceTree(document.querySelector('.juno-tree'));
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
It stores nothing: expansion and selection live on the DOM and belong to you. It
|
|
89
|
+
moves focus and dispatches `juno-tree-toggle` / `juno-tree-select` (bubbling,
|
|
90
|
+
cancelable, `detail.item`) — it does **not** expand, collapse, select or reorder,
|
|
91
|
+
because in a real outliner expanding a node may need to load it.
|
|
92
|
+
|
|
93
|
+
## Touch
|
|
94
|
+
|
|
95
|
+
The row holds `--juno-size-tap-min`, which becomes the 44px comfortable target on
|
|
96
|
+
a coarse pointer. The caret and the handle **paint** small so a dense tree stays
|
|
97
|
+
dense, and grow only their **hit area** with a transparent overlay — a 44px
|
|
98
|
+
painted caret would swallow the row it sits in.
|
|
99
|
+
|
|
100
|
+
**The reorder handle is explicit, and that is not a style choice.** Long-press-drag
|
|
101
|
+
is the obvious gesture and the wrong one: a tree sitting on or beside a pan/zoom
|
|
102
|
+
surface has to let the pan win, and a gesture that means "reorder" here and "pan"
|
|
103
|
+
one pixel to the left is a coin flip. The handle carries `touch-action: none` on
|
|
104
|
+
itself alone, so dragging it never scrolls while the rest of the row still pans.
|
|
105
|
+
|
|
106
|
+
## What junoui does not do
|
|
107
|
+
|
|
108
|
+
Reorder **logic** — the drop calculation, the model mutation, autoscroll. junoui
|
|
109
|
+
ships the affordance, its hit area, and the drop-target styling
|
|
110
|
+
(`data-juno-drop="before|after|into"`, plus `data-juno-dragging`). A drop lands
|
|
111
|
+
_between_ rows by default: the edge line says "between", a filled highlight says
|
|
112
|
+
"into this one", and those are different operations in a tree.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# The conformance kit — design
|
|
2
|
+
|
|
3
|
+
> **Status:** design, for review. Nothing here is implemented except where a
|
|
4
|
+
> section says "already shipped".
|
|
5
|
+
> **Ticket:** 20260826-036. **Owner:** iosdev.
|
|
6
|
+
|
|
7
|
+
## The problem, stated from evidence
|
|
8
|
+
|
|
9
|
+
junoui is consumed by an app that then has to re-derive junoui's own facts to
|
|
10
|
+
use it safely. One week of auditing a single consumer (nexora) produced this
|
|
11
|
+
list, and every item is a thing the consumer had to build because the library
|
|
12
|
+
did not ship it:
|
|
13
|
+
|
|
14
|
+
| What the consumer built | Because junoui did not ship |
|
|
15
|
+
| --------------------------------------------------- | --------------------------------------------------- |
|
|
16
|
+
| `junoClasses.ts` + a class-name test | any statement of which class names exist |
|
|
17
|
+
| a cascade resolver (`cssCascade.ts`) | a way to ask what a property resolves to |
|
|
18
|
+
| `toppill_fit_verify.py`, `measure_mobile_chrome.py` | a way to measure a layout claim on a device profile |
|
|
19
|
+
| a three-bucket `env()` discipline, in prose | a rule for who adds the safe-area inset |
|
|
20
|
+
| `--nx-dock-clear` and the 320px arithmetic | a published item budget |
|
|
21
|
+
| local tap floors on four controls | floors on the primitives those controls imitate |
|
|
22
|
+
|
|
23
|
+
Two things make this worse than duplication.
|
|
24
|
+
|
|
25
|
+
**The consumer's copy drifts, silently and in the safe direction.** The dock
|
|
26
|
+
arithmetic subtracted 12px of inline padding where the pill spends 8, so every
|
|
27
|
+
per-item width was ~0.8px low for months. Nothing was visibly wrong. The number
|
|
28
|
+
was simply not the library's number any more.
|
|
29
|
+
|
|
30
|
+
**The library's own defects survive because nobody can see them from inside.**
|
|
31
|
+
`.juno-seg__option` sat in a `touch-action` list for as long as it existed; the
|
|
32
|
+
shipped class is `.juno-seg__opt`. `:where()` matched nothing, the rule parsed,
|
|
33
|
+
every other member kept working. One character, invisible to lint, to the
|
|
34
|
+
build, and to a screenshot. `.juno-list__item` was the same defect in the same
|
|
35
|
+
two lists. Both were found this week by a test written for the first one.
|
|
36
|
+
|
|
37
|
+
The kit's thesis: **mobile correctness is a library concern, and the library
|
|
38
|
+
should be able to prove its own claims.**
|
|
39
|
+
|
|
40
|
+
## Design principles
|
|
41
|
+
|
|
42
|
+
1. **A fact is published once and derived everywhere else.** If a consumer can
|
|
43
|
+
compute a number from junoui's geometry, junoui computes it and exports it.
|
|
44
|
+
`--juno-dock-edge-offset` is the shape: one declaration feeding both the
|
|
45
|
+
margin that paints and the clearance that reserves.
|
|
46
|
+
2. **A name is generated, never typed twice.** Every list of junoui class names
|
|
47
|
+
inside junoui — `:where()` sets, docs tables, the manifest — comes from one
|
|
48
|
+
source. The `__option`/`__opt` class of defect stops being expressible.
|
|
49
|
+
3. **A guard measures the artifact it is about.** Against the built bundle,
|
|
50
|
+
through a layout engine where used values matter, with a control proving the
|
|
51
|
+
measurement can register the defect. Stated as a rule because this kit's own
|
|
52
|
+
first test failed it: see [Anti-goals](#anti-goals).
|
|
53
|
+
4. **The library ships the instrument, not just the answer.** A consumer that
|
|
54
|
+
can only read junoui's numbers still cannot check its own composition. The
|
|
55
|
+
doctor (F) exists so the answer is reproducible outside this repo.
|
|
56
|
+
|
|
57
|
+
## A. Class contract shipped
|
|
58
|
+
|
|
59
|
+
**Ship** `dist/classes.json`, generated by `scripts/bundle-css.mjs` from the
|
|
60
|
+
selectors it already parses:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"version": "0.7.0",
|
|
65
|
+
"components": {
|
|
66
|
+
"seg": { "block": "juno-seg", "elements": ["juno-seg__opt"], "modifiers": ["juno-seg--sm"] }
|
|
67
|
+
},
|
|
68
|
+
"all": ["juno-seg", "juno-seg__opt", "..."]
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Ship** `junoui/testing`, a framework-agnostic helper:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import { assertJunoClasses } from 'junoui/testing';
|
|
76
|
+
assertJunoClasses(['src/**/*.tsx']); // throws listing every juno-* class no rule defines
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Generalizes nexora's `junoClasses.ts`, which caught 11 misspelled BEM names
|
|
80
|
+
that compiled silently and broke actions on phones.
|
|
81
|
+
|
|
82
|
+
**Shipped**, with two revisions the implementation forced. Both came from
|
|
83
|
+
running the helper against a real consumer rather than from taste.
|
|
84
|
+
|
|
85
|
+
**The helper checks `all` by default, not `public`.** Measured on the 0.7.0
|
|
86
|
+
build: 310 classes have rules, 277 are named anywhere in `docs/`. The 33-name
|
|
87
|
+
difference is not an internals list — it is `juno-sr-only`, `juno-bg-s0`,
|
|
88
|
+
`juno-hide-below-lg`, `juno-eyebrow` and friends: public utilities nobody wrote
|
|
89
|
+
up. Defaulting to `public` would have failed consumers for using shipped API.
|
|
90
|
+
`public` stays available as a stricter surface, and the docs gap is junoui's to
|
|
91
|
+
close, tracked separately.
|
|
92
|
+
|
|
93
|
+
**The claim is "junoui ships nothing by this name", not "this is not a class".**
|
|
94
|
+
A consumer writes `junoPx('juno-pillbar-gap')` and `` `#juno-i-${n}` ``, and no
|
|
95
|
+
regex over source text distinguishes those from a class. First run against
|
|
96
|
+
nexora reported 24 names; 8 were tokens, an icon-id template and a keyframe —
|
|
97
|
+
all names junoui does ship. So the manifest carries `tokens`, `keyframes` and
|
|
98
|
+
`icons` alongside the classes, and component-local custom properties are read
|
|
99
|
+
from the **bundle**, not only `juno-tokens.css` (that alone accounted for three
|
|
100
|
+
of the eight).
|
|
101
|
+
|
|
102
|
+
Final state on nexora's 133 source files: the only reports are 13 in the fixture
|
|
103
|
+
file that deliberately names wrong classes, and 3 consumer-owned names.
|
|
104
|
+
|
|
105
|
+
## B. Tap floors owned by the library
|
|
106
|
+
|
|
107
|
+
**Already shipped**, this week, and the evidence for the rest of the section:
|
|
108
|
+
`.juno-seg__opt` had no floor at all and computed 25.39px (20260826-025);
|
|
109
|
+
`.juno-btn--sm` shipped a 24px target that 40 consumer call sites used to mean
|
|
110
|
+
"secondary" (20260826-026).
|
|
111
|
+
|
|
112
|
+
**Remaining work:**
|
|
113
|
+
|
|
114
|
+
1. **Audit every interactive primitive** for a coarse-pointer floor and record
|
|
115
|
+
the result in one table, in the repo, generated by the doctor rather than
|
|
116
|
+
written by hand. Anything that cannot hold 44 states why in that table.
|
|
117
|
+
2. **Generate the `touch-action` and tap-highlight `:where()` lists from the
|
|
118
|
+
class manifest.** Today they are two hand-maintained lists that had drifted
|
|
119
|
+
from the classes _and from each other_ — the tap-highlight set is a strict
|
|
120
|
+
subset of the touch-action set, and nothing says whether that is deliberate.
|
|
121
|
+
Generated, the typo class of defect cannot exist and the divergence becomes
|
|
122
|
+
a visible decision.
|
|
123
|
+
|
|
124
|
+
**Shipped**, and the question is answered: **one set**, decided on the rules'
|
|
125
|
+
own rationales rather than merged for tidiness. The tap-highlight rule exists so
|
|
126
|
+
a UA square "never flashes past a rounded control on tap", and every name the
|
|
127
|
+
shorter list omitted is a rounded tappable — `.juno-chip` and
|
|
128
|
+
`.juno-pillbar__overflow` are 999px pills, `.juno-seg__opt` and
|
|
129
|
+
`.juno-toggle-btn` carry `radius-3`. The omission had no stated reason and the
|
|
130
|
+
rationale covers them, so it was an omission, not a decision.
|
|
131
|
+
|
|
132
|
+
`src/css/touch-surfaces.mjs` declares the set; `bundle-css.mjs` emits both
|
|
133
|
+
rules. `touch-action` stays out of the coarse block (a hybrid device reports a
|
|
134
|
+
fine primary pointer while still taking touch input); the highlight stays in it.
|
|
135
|
+
|
|
136
|
+
The guard checks the declared set against the **component sources**, not the
|
|
137
|
+
manifest — the manifest is built from the bundle, and the bundle now contains
|
|
138
|
+
these lists, so a misspelled member would vouch for itself there.
|
|
139
|
+
|
|
140
|
+
## C. Pointer-first responsiveness
|
|
141
|
+
|
|
142
|
+
**The defect:** a landscape iPhone is 844×390. It is wider than `md`, so a
|
|
143
|
+
width-keyed rail serves it the desktop rail on every screen, on a device held
|
|
144
|
+
in two hands. Width has never been the question.
|
|
145
|
+
|
|
146
|
+
**Ship one blessed mechanism**, and use it in junoui's own CSS so it cannot be
|
|
147
|
+
advisory:
|
|
148
|
+
|
|
149
|
+
```css
|
|
150
|
+
/* the coarse-pointer condition, stated once */
|
|
151
|
+
@media (pointer: coarse) { ... }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
CSS has no portable way to name a media query (`@custom-media` is a draft and
|
|
155
|
+
junoui runs no PostCSS), so the honest form is: **junoui's CSS writes the
|
|
156
|
+
literal, and the string is exported for JS**, with a test asserting the two
|
|
157
|
+
agree.
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
import { COARSE_POINTER, useCoarsePointer } from 'junoui/testing'; // name TBD
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Key `rail--responsive`, the modal→sheet swap, and `density: auto` on it.
|
|
164
|
+
|
|
165
|
+
**Open question for review:** whether the condition is `(pointer: coarse)` or
|
|
166
|
+
`(pointer: coarse) and (max-height: …)`. A coarse-pointer 27" kiosk is a real
|
|
167
|
+
device and wants the rail. Nexora already ships a JS-side coarse branch in
|
|
168
|
+
Settings; that call site is the place to look before deciding.
|
|
169
|
+
|
|
170
|
+
## D. Safe-area discipline in the library
|
|
171
|
+
|
|
172
|
+
Adopt nexora's three-bucket `env()` doc verbatim — it is the clearest statement
|
|
173
|
+
of this that exists in either repo, and junoui's own triage is missing its
|
|
174
|
+
third bucket:
|
|
175
|
+
|
|
176
|
+
| Bucket | Rule | Because |
|
|
177
|
+
| ------------------- | ------------------ | ------------------------------------------------------------------ |
|
|
178
|
+
| **edge padding** | `max(base, env())` | content already inside the box; the inset replaces the gap |
|
|
179
|
+
| **clearance** | `base + env()` | the chrome's offset already contains the inset; reserve must match |
|
|
180
|
+
| **floating chrome** | `base + env()` | a floating element sits off the edge; gap and inset stack |
|
|
181
|
+
|
|
182
|
+
Then: **every floating-chrome primitive applies its own bucket** — `.juno-toast`,
|
|
183
|
+
`.juno-pillbar`, `.juno-dock` — through a token the consumer can restate, which
|
|
184
|
+
`--juno-dock-edge-offset` already demonstrates. Extends the xreqs filed as
|
|
185
|
+
20260803-037 and 20260815-053.
|
|
186
|
+
|
|
187
|
+
## E. Published budgets
|
|
188
|
+
|
|
189
|
+
**Already shipped** for the dock (20260826-027): `--juno-dock-items`,
|
|
190
|
+
`--juno-dock-item-inline`, `--juno-dock-fit-inline`, `--juno-dock-chrome-inline`,
|
|
191
|
+
`--juno-dock-avail`, with the terms declared once and consumed by both the box
|
|
192
|
+
and the sum.
|
|
193
|
+
|
|
194
|
+
**Remaining:** the same treatment for `.juno-pillbar`, which publishes
|
|
195
|
+
`--juno-pillbar-item/-gap/-pad` but no derived budget, so a consumer's capacity
|
|
196
|
+
planner still does the division itself.
|
|
197
|
+
|
|
198
|
+
**Recorded limit, so nobody re-opens it:** junoui cannot publish a _scale_
|
|
199
|
+
floor. "What scale keeps a 44px target?" is `44px / item-width`, a ratio of two
|
|
200
|
+
lengths, and CSS cannot divide by a length. The library publishes the two
|
|
201
|
+
lengths; a consumer that must scale rather than drop compares them.
|
|
202
|
+
|
|
203
|
+
## F. junoui doctor
|
|
204
|
+
|
|
205
|
+
A runnable conformance probe a consumer executes against **its own** build:
|
|
206
|
+
|
|
207
|
+
```sh
|
|
208
|
+
npx junoui doctor --url http://localhost:5173 --profiles phone,phone-landscape
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Checks, generalized from nexora's `toppill_fit_verify.py` and
|
|
212
|
+
`measure_mobile_chrome.py`:
|
|
213
|
+
|
|
214
|
+
- every element matching a `public` manifest class holds its tap floor
|
|
215
|
+
- both orientations, because landscape is where the width assumption fails
|
|
216
|
+
- no horizontal overflow at 320px
|
|
217
|
+
- floating chrome clears the safe area, per bucket
|
|
218
|
+
- a consumer class does not out-resolve a junoui declaration it did not mean to
|
|
219
|
+
|
|
220
|
+
Reports as a table, exits non-zero, and prints what it did **not** cover.
|
|
221
|
+
|
|
222
|
+
## Anti-goals
|
|
223
|
+
|
|
224
|
+
**No advisory rules.** Anything in G that the doctor cannot check is a
|
|
225
|
+
checklist item a reader will skip. Prefer fewer checks that run.
|
|
226
|
+
|
|
227
|
+
**No guard that has not been shown to fail.** This kit's own first tap-target
|
|
228
|
+
test passed with the fix deleted: it asserted against a showcase page, and
|
|
229
|
+
something in that page already held the height, so it could not fail. Measured:
|
|
230
|
+
24px off the bundle, 44px off `/showcase/buttons.html`, same markup, same
|
|
231
|
+
emulation (20260826-030, cause still unidentified). Every check the doctor
|
|
232
|
+
ships must be mutation-tested against a reintroduced defect before it counts.
|
|
233
|
+
|
|
234
|
+
**No release gate that proves less than it claims.** `gate:consumer` reported
|
|
235
|
+
GREEN twice against a consumer checkout 260 commits behind its own develop —
|
|
236
|
+
the guard that would have failed did not exist on that branch (20260826-039).
|
|
237
|
+
|
|
238
|
+
## Sequencing
|
|
239
|
+
|
|
240
|
+
| Slice | Contents | Depends on |
|
|
241
|
+
| ----- | ---------------------------------------------------- | ---------- |
|
|
242
|
+
| ~~1~~ | ~~A (manifest + helper)~~ — **shipped** | — |
|
|
243
|
+
| ~~2~~ | ~~B2 (generate the `:where()` lists)~~ — **shipped** | 1 |
|
|
244
|
+
| 3 | C (one pointer-first mechanism) | — |
|
|
245
|
+
| 4 | D (buckets + floating chrome owns its inset) | 3 |
|
|
246
|
+
| 5 | E (pillbar budget) | — |
|
|
247
|
+
| 6 | F (doctor) | 1, 3 |
|
|
248
|
+
| 7 | G (checklist, reduced to what F checks) | 6 |
|
|
249
|
+
|
|
250
|
+
Slices 1 and 2, B1 and E-for-dock are done. 024–027 are absorbed and closed.
|
|
251
|
+
|
|
252
|
+
**Review asks:** the three open questions above (A's public subset, B's two
|
|
253
|
+
lists, C's condition), and whether slice 6 is worth its cost before slice 1
|
|
254
|
+
lands — the doctor is the piece a consumer feels, and slices 1–5 are the pieces
|
|
255
|
+
that stop the defects existing.
|