@momoi-labs/kiso 0.12.1 → 0.13.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/README.md CHANGED
@@ -74,3 +74,9 @@ to open it locally, or `npm run build:gallery` to produce the static site.
74
74
 
75
75
  See [Publishing Kiso](docs/publishing.md) for the first npm publication, trusted
76
76
  publisher setup, release PRs, and version tags.
77
+
78
+ Run `npx playwright install chromium` once, then `npm run check:browser` to
79
+ check touch targets, taps, disabled controls, and keyboard interaction for
80
+ Lifecycle, Checkbox, Switch, Tabs, and TimeRangeControl in Chromium. Checks
81
+ cover coarse and fine pointers at 390px and 1280px in both themes. Set
82
+ `SCREENSHOT_DIR=artifacts/touch-controls` to save screenshots.
@@ -41,11 +41,13 @@ behavioral reference where one exists.
41
41
  - [Dot](dot.md): Adds a decorative status mark beside readable text.
42
42
  - [DropdownMenu](dropdown-menu.md): Presents contextual actions anchored to a specific object or trigger.
43
43
  - [EmptyState](empty-state.md): Replaces an empty collection with an explanation and optional next action.
44
+ - [FilterInput](filter-input.md): Turns typed conditions into editable field/operator/value chips, with IN lists and nested AND/OR groups.
44
45
  - [KV](kv.md): Describes one object through named facts.
45
46
  - [LogView](log-view.md): Displays scrollable log output with follow-tail behavior.
46
47
  - [Search](search.md): Filters visible content such as a list or table.
47
48
  - [Sparkline](sparkline.md): Draws the shape of one metric series at cell size.
48
49
  - [Stat](stat.md): Presents a named metric with optional change and context.
50
+ - [StatusBadge](status-badge.md): Says a record's state in one of three tones, pulsing while work goes.
49
51
  - [StepBar](step-bar.md): Summarises a run as one segment per step, coloured by state.
50
52
  - [StepList](step-list.md): Lists a run's steps with a state, a label, a timing, and an output.
51
53
  - [Table / DataTable](table.md): Presents structured records with optional sorting, selection, filtering, and pagination.
@@ -60,6 +62,7 @@ behavioral reference where one exists.
60
62
  - [BrandMark](brand-mark.md): Decorative letter or icon beside a product name.
61
63
  - [Breadcrumb](breadcrumb.md): Shows the current location within a hierarchy.
62
64
  - [Header](header.md): Composes persistent application navigation and global actions.
65
+ - [Lifecycle](lifecycle.md): Groups a detail screen's status, verbs, and destructive action in one row.
63
66
  - [Navigation](navigation.md): Provides a generic semantic container for destination links.
64
67
  - [PageHeader](page-header.md): Composes a page title, optional subtitle, and page-scoped action Buttons.
65
68
  - [Pagination](pagination.md): Moves through known pages while exposing the current position.
@@ -25,6 +25,7 @@ which options a chip accepts. ChipInput never parses.
25
25
  | One value from a known set | [Select](select.md) | Single choice. |
26
26
  | Free text that is not a list | [Input](input.md) or [Textarea](textarea.md) | Nothing to chip. |
27
27
  | Filtering what is already on screen | [Search](search.md) | A query, not stored values. |
28
+ | Filtering by fields, operators, and logical groups | [FilterInput](filter-input.md) | Typed conditions with IN lists and AND/OR groups. |
28
29
  | Running a global action | [CommandPalette](command-palette.md) | Commands, not data. |
29
30
 
30
31
  ## Anatomy
@@ -93,11 +94,11 @@ form column.
93
94
  | hover | Quiet border emphasis on the box. A hovered version segment deepens its fill. | `--color-border-strong`, `--color-accent-surface`. |
94
95
  | focus | One ring around the whole box, never around the bare input. | `--color-ring`. |
95
96
  | editing a segment | The value or the options become an input sized to their content; the chip takes an accent border and a confirm control appears. | `--color-primary`, `--color-accent-surface`. |
96
- | no options | Only the `+` segment remains, in the subtle foreground. | `--color-subtle-foreground`. |
97
+ | no options | Only the `+` segment remains, in the muted foreground. | `--color-muted-foreground`. |
97
98
  | chip invalid | The single chip is marked, not the field. Say why next to the field. | `--color-danger-surface`, `--color-danger-border`, `--color-danger`. |
98
99
  | field invalid | `aria-invalid` on the box plus [ValidationMessage](validation-message.md). | `--color-danger`. |
99
100
  | disabled | The box and every chip control are unavailable; chips stay readable. | `--color-disabled-surface`, `--color-disabled`. |
100
- | empty | Placeholder in the input showing the shape of one entry. | `--color-subtle-foreground`. |
101
+ | empty | Placeholder in the input showing the shape of one entry. | `--color-muted-foreground`. |
101
102
 
102
103
  An invalid chip and an invalid field are different failures. A version that
103
104
  does not exist marks the chip; "add at least one dependency" marks the field.
@@ -156,7 +157,7 @@ ChipInput must have an explicit submit Button.
156
157
  ## Tokens
157
158
 
158
159
  Box and input follow [Input](input.md): `--color-card`, `--color-input`,
159
- `--color-border-strong`, `--color-foreground`, `--color-subtle-foreground`,
160
+ `--color-border-strong`, `--color-foreground`, `--color-muted-foreground`,
160
161
  `--color-ring`, `--color-disabled`, `--color-disabled-surface`, `--radius-md`,
161
162
  `--shadow-xs`, `--size-control-md`, `--spacing-xs` padding,
162
163
  `--motion-duration-fast` and `--motion-easing-standard`.
@@ -164,8 +165,8 @@ Box and input follow [Input](input.md): `--color-card`, `--color-input`,
164
165
  Chips: `--color-secondary`, `--color-secondary-foreground`, `--radius-sm`,
165
166
  `--size-control-sm`, `--type-size-label`, `--font-mono` and
166
167
  `--type-size-metadata` for the segments, `--color-muted-foreground` for the
167
- scope and for an option's name, `--color-subtle-foreground` for the `=`, the
168
- brackets and the empty `+`, `--color-border` for the rules between segments,
168
+ scope, option names, `=`, brackets and the empty `+`, `--color-border` for
169
+ the rules between segments,
169
170
  `--color-card` for the value and option segments with `--color-accent-surface`
170
171
  when hovered, `--color-foreground` for an option's value, `--color-link` for
171
172
  the version,
@@ -56,7 +56,7 @@ error — errors are Alert. Do not color the whole EmptyState with status
56
56
  tokens.
57
57
 
58
58
  Surface tokens: text `--color-foreground` / `--color-muted-foreground` on
59
- the surrounding `--color-surface` or `--color-background`. Icon uses
59
+ `--color-card` under the hatch that marks the region as not data. Icon uses
60
60
  `--color-muted-foreground` unless it is purely decorative brand chrome.
61
61
 
62
62
  ## Sizes
@@ -121,8 +121,7 @@ PageHeader.
121
121
 
122
122
  ## Tokens
123
123
 
124
- `--color-foreground`, `--color-muted-foreground`, `--color-surface` /
125
- `--color-background`, optional icon `--color-muted-foreground`, `--spacing-lg`
124
+ `--color-foreground`, `--color-muted-foreground`, `--color-card`, optional icon `--color-muted-foreground`, `--spacing-lg`
126
125
  padding (`--spacing-md` for `sm`), `--spacing-sm` gap, `--radius-lg` on the
127
126
  icon frame, the hatch tokens (`--color-hatch`, `--hatch-line`,
128
127
  `--hatch-period`, `--hatch-angle`), and
@@ -0,0 +1,146 @@
1
+ # FilterInput
2
+
3
+ Build a structured search by typing. A complete condition becomes a chip with
4
+ three editable segments: field, operator, and value. `IN` values share one
5
+ segment. Parentheses visibly group chips, including nested groups.
6
+
7
+ Use FilterInput for searches that need typed comparisons or Boolean groups.
8
+ Use [Search](search.md) for a plain text query and [ChipInput](chip-input.md) for
9
+ structured lists such as dependencies. Existing ChipInput APIs and behavior do
10
+ not change.
11
+
12
+ ## Ownership and API
13
+
14
+ The controlled `value` contains confirmed conditions and groups.
15
+ `onValueChange` receives each valid change. The product owns the field schema,
16
+ allowed operators and values, query execution, loading, pagination, and errors
17
+ from its data source. FilterInput never generates or executes SQL.
18
+
19
+ | Prop | Contract |
20
+ | --- | --- |
21
+ | `label` | Required visible label, associated with the trailing input. |
22
+ | `fields` | Field definitions with `key`, optional `label`, `type`, optional `values`, `operators`, and `nullable`. |
23
+ | `value`, `onValueChange` | Controlled `FilterNode[]` and its change callback. |
24
+ | `onDraftChange` | Optional notification of trailing uncommitted text, including whole-expression editing. |
25
+ | `disabled` | Disables the input, suggestions, segment editing, connectors, removal, and actions. |
26
+ | `id`, `placeholder`, `className` | Input ID, optional syntax example, and root layout styling. |
27
+
28
+ Field keys are identifiers without spaces or syntax punctuation. Use `label`
29
+ for a friendly display name. `text` fields accept `=`, `!=`, `IN`, and
30
+ `CONTAINS`; `number` fields accept equality, inequality, ordering, and `IN`.
31
+ `nullable` adds `IS NULL`. `operators` restricts the field's supported operators.
32
+ `values` restricts values to the supplied list and supplies autocomplete.
33
+ Numbers must be finite; the product can apply additional domain constraints.
34
+
35
+ Conditions contain `kind: "condition"`, `field`, `operator`, `value`, and
36
+ `join`. Groups contain `kind: "group"`, `children`, and `join`. The first
37
+ node's join is ignored. Later nodes join the preceding expression with `AND`
38
+ or `OR`. Within each group, AND binds before OR. Explicit groups always keep
39
+ their parentheses. IN values are arrays, number values are numbers, and
40
+ IS NULL has a null value. An empty root means no filters; empty child groups
41
+ are invalid.
42
+
43
+ The separate `parseFilterExpression(text, fields)` helper returns either
44
+ `{ ok: true, value }` or `{ ok: false, error }`. It never returns a partially
45
+ parsed query. Pass `{ allowLeadingJoin: true }` only when appending to an
46
+ existing expression. `serializeFilterExpression` converts confirmed nodes back
47
+ to editable text. `getFilterSuggestions` exposes the same completion logic for
48
+ product adapters. These helpers do not alter ChipInput.
49
+
50
+ ## Typing and syntax
51
+
52
+ | Input | Meaning |
53
+ | --- | --- |
54
+ | `status=active` or `status:active` | Equality. |
55
+ | `status!=paused` | Inequality. |
56
+ | `lag>=100` | Numeric comparison; `>`, `<`, and `<=` also work. |
57
+ | `owner CONTAINS Mina` or `owner~Mina` | Text containment; matching rules belong to the product. |
58
+ | `region IN (eu, us)` | Any listed value. Brackets are also accepted. |
59
+ | `owner IS NULL` | Missing value for a nullable field. |
60
+ | `(status=active OR status=paused) AND region=eu` | Grouped conditions. |
61
+
62
+ Whitespace after a complete scalar condition creates a chip. Closing an IN
63
+ list or complete group also commits it. Enter confirms complete text without
64
+ a trailing delimiter. Adjacent conditions use AND. Operators and field keys
65
+ are case-insensitive; text values retain their case.
66
+
67
+ Quote spaces, reserved words, or commas inside a value, for example
68
+ `owner="Ana Silva"` or `owner IN ("Doe, Jane", Mina)`. Single and double quotes
69
+ work; backslash escapes protect quotes and backslashes. A trailing comma before
70
+ the list closes is ignored: `(eu, us, )` equals `(eu, us)`. Duplicate list
71
+ values collapse. Empty lists and missing values between commas remain invalid.
72
+
73
+ Incomplete text remains a draft. An unfinished group never commits only its
74
+ first condition. Invalid input displays a correction beside the field when
75
+ confirmation is attempted; it does not replace confirmed filters. Input method
76
+ composition does not create chips until composition ends. Editing inside the
77
+ draft does not auto-commit while the caret is away from its end.
78
+
79
+ ## Editing and removal
80
+
81
+ Click a chip segment to edit only that field, operator, or value. Click a
82
+ group's opening parenthesis to edit that group's expression. Enter or Save
83
+ confirms; Escape or Cancel restores the saved value. Only one editor is active
84
+ at a time. Results continue to use the saved filter until confirmation.
85
+
86
+ Changing a scalar operator to IN wraps the existing value in an array. Changing
87
+ a multi-value IN to a scalar operator asks for a replacement value instead of
88
+ discarding extra values. Changing to IS NULL removes the value. Unsupported
89
+ field/operator/value combinations remain editable with a validation message.
90
+
91
+ Click a connector to switch AND/OR. Edit expression restores the entire query
92
+ as text so existing conditions can be regrouped. Enter saves that draft;
93
+ Escape cancels it. Clear filters removes both confirmed filters and drafts.
94
+ Removing the last condition in a group removes that empty group as well.
95
+
96
+ ## Keyboard and accessibility
97
+
98
+ | Key or action | Behavior |
99
+ | --- | --- |
100
+ | ArrowDown / ArrowUp | Open or navigate contextual suggestions. |
101
+ | Tab | Accept a visible suggestion. With suggestions dismissed, continue normal focus navigation. |
102
+ | Shift+Tab | Move backward without accepting a suggestion. |
103
+ | Enter | Confirm valid text, or accept a suggestion for incomplete text. Never implicitly submit the surrounding form. |
104
+ | Escape | Dismiss suggestions; cancel a segment, group, or whole-expression edit. |
105
+ | Backspace in an empty trailing input | Restore the last condition or group as text for editing. |
106
+ | Click empty box space | Focus the trailing input. |
107
+
108
+ The trailing input is a labeled combobox. The listbox, active descendant, and
109
+ expanded state describe only visible suggestions. Chip controls are native
110
+ buttons with names that include their condition. Groups have accessible names.
111
+ Errors use `aria-invalid`, associated descriptions, and alert text. A polite
112
+ status announces committed changes. Confirmation restores focus to the edited
113
+ segment; removal returns it to the trailing input.
114
+
115
+ Provide an explicit product search action when results require submission. Do
116
+ not silently discard pending text on submit; observe `onDraftChange` or ask the
117
+ person to finish the draft first. This component does not persist filter state.
118
+
119
+ ## Layout, states, and tokens
120
+
121
+ One continuous-typing interaction. Required states are empty, draft with
122
+ suggestions, confirmed chips, IN list, nested groups, editing, invalid draft,
123
+ and disabled. An empty result set belongs to the collection, not this control.
124
+
125
+ The box grows vertically as chips wrap. Groups wrap internally; they must not
126
+ force horizontal page scrolling on narrow screens. Touch targets use
127
+ `--size-touch-min` on coarse pointers. Desktop segments use
128
+ `--size-control-sm`. Maintain visible keyboard focus in both themes.
129
+
130
+ Compose the existing chip, input, button, and menu surfaces. Consume
131
+ `--color-card`, `--color-foreground`, `--color-muted-foreground`,
132
+ `--color-link`, `--color-border`, `--color-border-strong`,
133
+ `--color-accent-surface`, `--color-accent-surface-hover`, `--color-selected`,
134
+ `--color-selected-foreground`, `--color-ring`, and `--color-disabled`.
135
+ Use the existing spacing, radius, font, and shadow tokens. No new palette or
136
+ spacing scale is introduced.
137
+
138
+ ## Boundaries
139
+
140
+ This is a small filter grammar, not SQL or a query-language interpreter. It
141
+ supports explicit Boolean groups but not NOT groups, field-to-field comparisons,
142
+ functions, or arbitrary SQL. The parser limits recursive nesting to 64 levels.
143
+
144
+ Validate the field schema and user filters again at the application boundary.
145
+ A server adapter maps allowed field identifiers and binds values as parameters.
146
+ Never concatenate editable values into executable SQL.
@@ -44,7 +44,7 @@ for large. It does not reduce text or target size below accessible product norms
44
44
 
45
45
  | State | Behavior |
46
46
  | --- | --- |
47
- | Default | `--color-surface` background, `--color-border` outline, `--color-foreground` value; placeholder uses `--color-subtle-foreground`. |
47
+ | Default | `--color-surface` background, `--color-border` outline, `--color-foreground` value; placeholder uses `--color-muted-foreground`. |
48
48
  | Hover | Border emphasis may increase without changing layout or implying focus. |
49
49
  | Focus | Visible `--color-focus` ring; do not rely on border color alone. |
50
50
  | Active | Native text selection and editing behavior; no separate persistent visual state. |
@@ -85,7 +85,7 @@ silently disable a field merely to show activity.
85
85
  ## Tokens
86
86
 
87
87
  Use only semantic roles: `--color-surface`, `--color-foreground`,
88
- `--color-subtle-foreground`, `--color-border`, `--color-focus`,
88
+ `--color-muted-foreground`, `--color-border`, `--color-focus`,
89
89
  `--color-disabled`, and `--color-danger`; the five property-qualified body
90
90
  typography tokens; `--spacing-xs`, `--spacing-sm`, `--spacing-md`, and
91
91
  `--spacing-lg` as mapped above; `--radius-md`; `--shadow-sm` for elevated
@@ -0,0 +1,80 @@
1
+ # Lifecycle
2
+
3
+ ## Purpose
4
+
5
+ Lifecycle is the header row of a detail screen: what the record is, what you
6
+ can do to it, and the one action you do not want to hit by accident. It sits
7
+ to the right of the [PageHeader](page-header.md) inside `.between`. See
8
+ [List-detail](../patterns/list-detail.md#list-and-detail-as-separate-screens).
9
+
10
+ ## Anatomy
11
+
12
+ ```
13
+ Lifecycle (.lifecycle)
14
+ ├── status cluster (role="group", "Status"): one or more StatusBadge
15
+ ├── verbs cluster (role="group", "Actions", optional): Buttons
16
+ └── destructive action (optional): Button with .btn-danger-ghost
17
+ ```
18
+
19
+ ```
20
+ [ Running │ HTTP 200 ] [ Start │ Stop │ Restart ] Remove
21
+ ```
22
+
23
+ Status and verbs are two objects because they are different kinds of thing.
24
+ Each cluster gives its children's borders and corners to one frame, and a
25
+ hairline between children says they are separate readings or separate
26
+ choices. Status is read, so its cluster has no shadow and each badge keeps
27
+ its tone. The verbs are pressed, so their cluster carries the surface and the
28
+ shadow. The destructive action stands outside both with a wider gap, so a
29
+ slip on Restart cannot land on Delete.
30
+
31
+ ## States
32
+
33
+ - **No verbs.** Pass no `actions` and the verbs cluster is not rendered. An
34
+ empty bordered box reads as something that failed to load.
35
+ - **Disabled verb.** A disabled Button keeps its place in the cluster with the
36
+ disabled surface. Start is off the whole time a thing runs; that is normal.
37
+ - **Work in progress.** The status cluster adds a pulsing
38
+ [StatusBadge](status-badge.md) that names the phase.
39
+
40
+ ## Sizes
41
+
42
+ Buttons are `size="sm"`; the clusters are `--size-control-sm` tall. At 720px
43
+ and below the row takes the width under the title, the verbs take a row of
44
+ their own, and the destructive action follows without its extra gap.
45
+
46
+ ## Accessibility
47
+
48
+ Each cluster is a `role="group"` named "Status" or "Actions". Every verb is
49
+ a text-labelled Button. The destructive action opens a confirmation; see
50
+ [Destructive actions](../patterns/destructive-actions.md). The focus ring of
51
+ a verb sits inside the cluster's edge.
52
+
53
+ ## Tokens and implementation
54
+
55
+ Frames use `--color-border` and `--radius-md`; the verbs cluster uses
56
+ `--color-card` and `--shadow-xs`; hover uses `--color-accent-surface-hover`;
57
+ disabled uses `--color-disabled-surface` and `--color-disabled`. The
58
+ component takes slots, not data: the screen keeps its own conditions for
59
+ which verbs are disabled.
60
+
61
+ ```tsx
62
+ <div className="between">
63
+ <PageHeader><PageHeaderTitle>paperless</PageHeaderTitle></PageHeader>
64
+ <Lifecycle
65
+ status={<StatusBadge tone="success">Running</StatusBadge>}
66
+ actions={<><Button size="sm">Stop</Button><Button size="sm">Restart</Button></>}
67
+ destructive={<Button size="sm" variant="ghost" className="btn-danger-ghost">Remove</Button>}
68
+ />
69
+ </div>
70
+ ```
71
+
72
+ ## When to use
73
+
74
+ - The header of a detail screen for a record with a state and verbs.
75
+
76
+ ## When NOT to use
77
+
78
+ - A list screen. Its one primary verb goes in PageHeader `actions`.
79
+ - A create screen. There is nothing to start or delete yet.
80
+ - Row actions in a table. Use a [DropdownMenu](dropdown-menu.md).
@@ -35,7 +35,7 @@ No size variants. The product chooses the frame height. The frame uses
35
35
  `--color-neutral-950`, text `--color-neutral-300`, `--color-border`,
36
36
  `--radius-surface`, and `--spacing-md` padding. Log text uses `--font-mono`,
37
37
  `--type-size-label`, and `--type-line-height-relaxed`. Timestamps use
38
- `--color-neutral-600` with `--spacing-sm` after them.
38
+ `--color-neutral-500` with `--spacing-sm` after them.
39
39
 
40
40
  ## States
41
41
 
@@ -50,7 +50,7 @@ FormField patterns.
50
50
  | `submit` | Applies on Enter or an explicit "Search" Button. Good for expensive server queries. |
51
51
 
52
52
  Appearance follows Input: `--color-surface`, `--color-border`,
53
- `--color-foreground`, placeholder `--color-subtle-foreground`. Leading icon
53
+ `--color-foreground`, placeholder `--color-muted-foreground`. Leading icon
54
54
  `--color-muted-foreground`.
55
55
 
56
56
  Do not add a "global" variant — that is CommandPalette.
@@ -127,7 +127,7 @@ a different global binding.
127
127
  ## Tokens
128
128
 
129
129
  Same semantic set as Input: `--color-surface`, `--color-foreground`,
130
- `--color-subtle-foreground`, `--color-muted-foreground`, `--color-border`,
130
+ `--color-muted-foreground`, `--color-border`,
131
131
  `--color-focus`, `--color-disabled`, `--spacing-sm` block and `--spacing-md`
132
132
  inline padding, `--radius-md`, the five property-qualified body typography
133
133
  tokens, `--motion-duration-fast`, and `--motion-easing-standard`.
@@ -0,0 +1,62 @@
1
+ # StatusBadge
2
+
3
+ ## Purpose
4
+
5
+ StatusBadge says the state of one record in one of three tones, the way a
6
+ light on a device does. Green is alive: a running machine, a build that is
7
+ going, a run that ended well. Red is failed. Neutral is what is not
8
+ happening: pending, stopped, disabled. It is a [Badge](badge.md) with a
9
+ [Dot](dot.md); use Badge directly for classification that is not a state.
10
+
11
+ ## Anatomy
12
+
13
+ ```
14
+ StatusBadge (Badge)
15
+ ├── Dot (decorative, pulses while work is going)
16
+ └── label (required)
17
+ ```
18
+
19
+ ## States
20
+
21
+ | Tone | Meaning | Badge variant |
22
+ | --- | --- | --- |
23
+ | `success` | Alive, or finished well. | `success` |
24
+ | `danger` | Failed. | `danger` |
25
+ | `neutral` | Not happening: pending, stopped, disabled. | `neutral` |
26
+
27
+ There is no fourth tone. "In progress" is not a colour: set `pulse` and let
28
+ the label name the phase ("Provisioning", "Building"), not "Running". A thing
29
+ that is merely up holds still.
30
+
31
+ ## Sizes
32
+
33
+ One size, the Badge's. In a [Lifecycle](lifecycle.md) status cluster the
34
+ badge takes the cluster's height.
35
+
36
+ ## Accessibility
37
+
38
+ The label carries the state; the dot is `aria-hidden`. Nothing depends on
39
+ colour alone. The pulse stops under `prefers-reduced-motion`, the same as
40
+ Dot's.
41
+
42
+ ## Tokens and implementation
43
+
44
+ Tones map to Badge variants and reuse their tokens. The pulse is Dot's
45
+ `pulse`. No Radix primitive.
46
+
47
+ ```tsx
48
+ <StatusBadge tone="success">Running</StatusBadge>
49
+ <StatusBadge tone="success" pulse>Provisioning</StatusBadge>
50
+ <StatusBadge tone="danger">Failed</StatusBadge>
51
+ <StatusBadge tone="neutral">Stopped</StatusBadge>
52
+ ```
53
+
54
+ ## When to use
55
+
56
+ - A record's state in a table cell or in a detail screen's Lifecycle row.
57
+
58
+ ## When NOT to use
59
+
60
+ - Classification without a state ("Beta", "v2"). Use Badge.
61
+ - A warning or information state. Use Badge `warning` or `info`, or an
62
+ [Alert](alert.md) when the person must act.
@@ -78,7 +78,7 @@ the loading status independently perceivable.
78
78
  ## Tokens
79
79
 
80
80
  Use the same exact mapping as Input: `--color-surface`, `--color-foreground`,
81
- `--color-subtle-foreground`, `--color-border`, `--color-focus`,
81
+ `--color-muted-foreground`, `--color-border`, `--color-focus`,
82
82
  `--color-disabled`, `--color-danger`; all five body typography properties;
83
83
  `--spacing-sm` block and `--spacing-md` inline padding; `--radius-md`;
84
84
  `--motion-duration-fast`; and `--motion-easing-standard`. No primitive colors
@@ -32,6 +32,7 @@ The growth model below is not theory. What it has produced so far:
32
32
  | --- | --- | --- |
33
33
  | **[ChipInput](components/chip-input.md)** | A self-hosted console had to collect a development image's dependencies: a list a person types, where every entry carries a backend, a version, and installer options. One form section per entry grew without limit and buried the list. | Added as a component contract with the segmented chip, the in-place value, and one segment per option. |
34
34
  | **[StepList](components/step-list.md) and [StepBar](components/step-bar.md)** | The self-host console's Last run tab walks a machine through eleven lifecycle steps over several minutes and wrote its own list of steps with a state each, replacing a progress bar and a two-thousand-line log to search. An image build and the Host's own bootstrap were about to copy it. [#98](https://github.com/momoi-labs/blueprint/issues/98) records the evidence. | Added as two contracts: the list inside a Split with the step's LogView beside it, and the bar for a summary, a table cell, or a toast. |
35
+ | **[Lifecycle](components/lifecycle.md), [StatusBadge](components/status-badge.md) and the screen layout classes** | self-host settled one layout for list, detail and create screens (its ADR-0024), and quiet-inbox adopted it (its ADR 0010) by copying two components and about sixty lines of CSS. [#112](https://github.com/momoi-labs/blueprint/issues/112) records the evidence. | Added as two contracts, the `.list-filters`, `.lifecycle`, `.detail-tabs` and `.form-page` classes, and the separate-screens section of [List-detail](patterns/list-detail.md#list-and-detail-as-separate-screens). |
35
36
  | **[Sparkline](components/sparkline.md)** | The self-host console collects metrics in-process, one sample per tick on an evenly spaced window, and needed the same shape three times over: a trend cell per application row, context under a KPI value, and one series per container. [#84](https://github.com/momoi-labs/blueprint/issues/84) records the evidence. | Added as a component contract and a Recharts-based component, single series, neutral by default, primary only for the series a tile is about. |
36
37
 
37
38
  ## Growth model: grow with real products
@@ -49,6 +49,33 @@ Discard restores the last saved values; Cancel leaves the task. Keep entries
49
49
  on failure. Announce feedback in the message region, not around the buttons.
50
50
  Immediate preferences and automatic filters do not require FormActions.
51
51
 
52
+ ## Create screen
53
+
54
+ A record with a recipe is too much for a dialog, so it gets a screen: a
55
+ PageHeader that says what is about to be made, and the same form the detail
56
+ screen edits, in a `Card.form-page`. The card is at most 720px wide and the
57
+ footer's corners follow it. There is no Lifecycle row and no tabs; the
58
+ footer is FormActions with Cancel and the one primary verb. From 1024px,
59
+ when the card is a direct child of `.page` inside an AppShell, it takes the
60
+ height the header leaves and the form scrolls inside it, so pass `sticky` to
61
+ FormActions and it rests on the card's edge. Below 1024px, or with
62
+ `data-fill="false"` on the card, the document scrolls and the actions end the
63
+ form in flow. The detail screen
64
+ is in [List-detail](list-detail.md#list-and-detail-as-separate-screens).
65
+
66
+ ```tsx
67
+ <PageHeader><PageHeaderTitle>Deploy an application</PageHeaderTitle></PageHeader>
68
+ <Card className="form-page">
69
+ <Form>
70
+ <div className="form-body">…</div>
71
+ <FormActions sticky>
72
+ <Button>Cancel</Button>
73
+ <Button type="submit" variant="primary">Deploy</Button>
74
+ </FormActions>
75
+ </Form>
76
+ </Card>
77
+ ```
78
+
52
79
  ## Flow
53
80
 
54
81
  ### Create
@@ -28,6 +28,7 @@ invent a per-list filter kit.
28
28
  | Toggle a single facet on/off | [Switch](../components/switch.md) or [Checkbox](../components/checkbox.md) | "Show only replicas with lag" |
29
29
  | Multiple values from a set | [Checkbox](../components/checkbox.md) group inside a [Popover](../components/popover.md) | "Region: ☑ eu ☑ us ☐ ap" |
30
30
  | Range or complex facet | [Popover](../components/popover.md) with form controls | "Lag: 0–500 ms" |
31
+ | Typed conditions across fields, with logical groups | [FilterInput](../components/filter-input.md) | `(status=active OR region IN (eu, us)) AND lag>100` |
31
32
  | Quick toggles (few, stable) | [Button](../components/button.md) `ghost` toggle or [Tabs](../components/tabs.md) | "All / Active / Archived" |
32
33
  | Active-filter summary | [Badge](../components/badge.md) per active filter or a text line | "Status: degraded ×" |
33
34
 
@@ -117,7 +118,9 @@ When filters exclude everything:
117
118
  - Do not invent a filter UI that competes with [Select](../components/select.md)
118
119
  / [Checkbox](../components/checkbox.md) / [Popover](../components/popover.md).
119
120
  If the facet is one-of-many, use Select; if many-of-many, use a Checkbox
120
- group in a Popover.
121
+ group in a Popover. Use [FilterInput](../components/filter-input.md) when
122
+ people need typed conditions, per-field operators, or nested AND/OR groups.
123
+ Its chips already show and remove active filters; do not duplicate them as Badges.
121
124
  - Filter state is part of the list's state, not global. Navigating away and
122
125
  back may restore it (documented per surface) but filters must not leak into
123
126
  unrelated lists.
@@ -101,9 +101,85 @@ Narrow (stacked detail):
101
101
  └──────────────────────────────────────┘
102
102
  ```
103
103
 
104
+ ## List and detail as separate screens
105
+
106
+ An alternative to the split: the list is one screen and each record opens a
107
+ detail screen of its own. Use it when a record carries more than a pane can
108
+ hold, such as a form, a log, or a terminal. Its create screen is in
109
+ [CRUD](crud.md#create-screen).
110
+
111
+ ### List screen
112
+
113
+ ```tsx
114
+ <PageHeader actions={<Button size="sm" variant="primary">Deploy application</Button>}>
115
+ <PageHeaderTitle>Applications</PageHeaderTitle>
116
+ </PageHeader>
117
+ <div className="list-filters">
118
+ <Search aria-label="Search applications" />
119
+ <Select>…</Select>
120
+ {filtering ? <Button size="sm" variant="ghost">Clear filters</Button> : null}
121
+ </div>
122
+ <div className="table-wrap">
123
+ <Table>…</Table>
124
+ <div className="table-footer">3 of 12 applications</div>
125
+ </div>
126
+ ```
127
+
128
+ - The one primary verb goes in PageHeader `actions`, not in the filter row.
129
+ Narrowing a list and adding to it are opposite intentions.
130
+ - `.list-filters`: Search first and growing, a Select sized to its content,
131
+ "Clear filters" only once something is filtered. Below 560px each control
132
+ takes the row.
133
+ - The count goes in `.table-footer`, not above the table.
134
+ - From 1024px, a `.table-wrap` that is the last child of `.page` fills the
135
+ height the header and filters leave: the rows scroll under the sticky
136
+ header and the count stays on the bottom edge, as a detail card does. Set
137
+ `data-fill="false"` on the `.table-wrap` to end the table with its last
138
+ row instead.
139
+ - A small record is created in a [Modal / Dialog](../components/modal-dialog.md).
140
+
141
+ ### Detail screen
142
+
143
+ ```tsx
144
+ <div className="between">
145
+ <PageHeader><PageHeaderTitle>paperless</PageHeaderTitle></PageHeader>
146
+ <Lifecycle status={…} actions={…} destructive={…} />
147
+ </div>
148
+ <Card className="detail-tabs">
149
+ <Tabs defaultValue="configuration">
150
+ <TabsList aria-label="Application details">…</TabsList>
151
+ <TabsContent value="configuration">
152
+ <Form>
153
+ <div className="form-body">…</div>
154
+ <FormActions sticky>…</FormActions>
155
+ </Form>
156
+ </TabsContent>
157
+ <TabsContent value="logs" className="detail-logs"><LogView>…</LogView></TabsContent>
158
+ </Tabs>
159
+ </Card>
160
+ ```
161
+
162
+ - The screen says its own name, and [Lifecycle](../components/lifecycle.md)
163
+ sits beside it. The destructive action lives in that row, not at the foot
164
+ of the form.
165
+ - One `Card.detail-tabs`, not a split. When the app reads facts back about
166
+ the record, a Summary tab comes first.
167
+ - `Form` does not render `.form-body`; write it. A panel whose only child is
168
+ the form gives its padding to `.form-body`, so a sticky
169
+ [FormActions](../components/form-actions.md) spans the panel.
170
+ - A panel with its own scroller, a [LogView](../components/log-view.md) or a
171
+ thread, takes `.detail-logs` or `.detail-pane` and reaches the card's edges.
172
+ - From 1024px, when the card is a direct child of `.page` inside an
173
+ [AppShell](../components/app-shell.md), the shell stops at the viewport
174
+ and the card takes the height the header leaves; each panel scrolls inside
175
+ it. Set `data-fill="false"` on the card to let the document scroll
176
+ instead. Below 1024px the document scrolls and an edge-to-edge panel is
177
+ 60vh tall.
178
+
104
179
  ## When to use
105
180
 
106
181
  - Collections where inspecting one item while keeping list context matters.
182
+ - Records with configuration and output of their own: use separate screens.
107
183
  - Entities that share the same columns and detail shape.
108
184
 
109
185
  ## When NOT to use
@@ -60,7 +60,7 @@ never text on dark.
60
60
  | `elevated-surface` | Menus, popovers, and dialogs. | `dark.700` | `white` |
61
61
  | `foreground` | Primary text and content that must carry the strongest hierarchy. | `dark.100` | `neutral.900` |
62
62
  | `muted-foreground` | Secondary text and labels. It remains normal-text eligible. | `dark.400` | `neutral.600` |
63
- | `subtle-foreground` | Placeholders, timestamps, and non-essential hints; large text only, never body copy. | `dark.450` | `neutral.500` |
63
+ | `subtle-foreground` | Large supporting text and non-text graphics where the surface provides 3:1 contrast; never small text. | `dark.450` | `neutral.500` |
64
64
  | `border` | Dividers and control outlines; never text. | `dark.600` | `neutral.300` |
65
65
  | `primary` | The primary fill: primary buttons, solid badges, checked controls, the brand mark. Not text-eligible on dark. | `#684bb5` | `#5b3fc4` |
66
66
  | `accent` | Secondary emphasis and highlights, not the page's main action. | `accent.300` | `accent.800` |
@@ -143,8 +143,9 @@ terminals — which cannot follow `color-scheme`, so their text cannot either.
143
143
 
144
144
  Use `foreground` for default reading, `muted-foreground` when content is
145
145
  secondary but still needs normal-text contrast, and `subtle-foreground` only
146
- for large or non-essential supporting copy. Use `primary` for the fill of the
147
- action that drives the current task; use `link` for anything that reads as a
146
+ for large supporting copy or non-text graphics on surfaces that provide at
147
+ least 3:1 contrast. Use `primary` for the fill of the action that drives the
148
+ current task; use `link` for anything that reads as a
148
149
  link or an active indicator, because the fill is not text-eligible on dark.
149
150
  Use `accent` to draw secondary attention without creating another primary
150
151
  action.
package/kiso/ui.css CHANGED
@@ -75,7 +75,7 @@ h1, h2, h3, h4, p, figure { margin: 0; }
75
75
  font-weight: var(--type-weight-semibold);
76
76
  letter-spacing: var(--type-letter-spacing-caps);
77
77
  text-transform: uppercase;
78
- color: var(--color-subtle-foreground);
78
+ color: var(--color-muted-foreground);
79
79
  }
80
80
  .t-mono, .mono, code, pre, kbd {
81
81
  font-family: var(--font-mono);
@@ -222,11 +222,13 @@ a:hover, .link:hover { text-decoration: underline; }
222
222
  .btn-primary[data-loading="true"]::after { border-color: var(--color-primary-foreground); border-top-color: transparent; }
223
223
  @keyframes spin { to { transform: rotate(360deg); } }
224
224
 
225
- /* Button group / segmented control */
225
+ /* Button group / segmented control. `.btn-group` twice, so the shared corners
226
+ beat the appearance rule that rounds every control and comes later in this
227
+ file; otherwise each button keeps its own corners and the group falls apart. */
226
228
  .btn-group { display: inline-flex; }
227
- .btn-group .btn { border-radius: 0; border-color: var(--color-input); background: var(--color-card); }
228
- .btn-group .btn:first-child { border-start-start-radius: var(--radius-md); border-end-start-radius: var(--radius-md); }
229
- .btn-group .btn:last-child { border-start-end-radius: var(--radius-md); border-end-end-radius: var(--radius-md); }
229
+ .btn-group.btn-group .btn { border-radius: 0; border-color: var(--color-input); background: var(--color-card); }
230
+ .btn-group.btn-group .btn:first-child { border-start-start-radius: var(--radius-md); border-end-start-radius: var(--radius-md); }
231
+ .btn-group.btn-group .btn:last-child { border-start-end-radius: var(--radius-md); border-end-end-radius: var(--radius-md); }
230
232
  .btn-group .btn + .btn { margin-inline-start: -1px; }
231
233
  .btn-group .btn:hover { background: var(--color-accent-surface); }
232
234
  .btn-group .btn[aria-pressed="true"] { background: var(--color-secondary); z-index: 1; }
@@ -357,7 +359,7 @@ fieldset.form-body { border: 0; margin: 0; }
357
359
  transition: border-color var(--motion-duration-fast) var(--motion-easing-standard);
358
360
  }
359
361
  .textarea { height: auto; min-height: 76px; padding-block: var(--spacing-sm); line-height: var(--type-line-height-normal); resize: vertical; }
360
- .input::placeholder, .textarea::placeholder { color: var(--color-subtle-foreground); }
362
+ .input::placeholder, .textarea::placeholder { color: var(--color-muted-foreground); }
361
363
  .input:hover, .select:hover, .textarea:hover { border-color: var(--color-border-strong); }
362
364
  .input:disabled, .select:disabled, .textarea:disabled {
363
365
  background: var(--color-disabled-surface);
@@ -395,10 +397,10 @@ fieldset.form-body { border: 0; margin: 0; }
395
397
  }
396
398
 
397
399
  /* chip input: several structured values in one field */
398
- .chip-input { position: relative; display: grid; gap: var(--spacing-xs); }
400
+ .chip-input { position: relative; display: grid; min-width: 0; gap: var(--spacing-xs); }
399
401
  .chip-input-box {
400
402
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--spacing-xs);
401
- width: 100%;
403
+ width: 100%; min-width: 0;
402
404
  min-height: var(--size-control-md);
403
405
  padding: var(--spacing-xs);
404
406
  border: 1px solid var(--color-input);
@@ -425,13 +427,14 @@ fieldset.form-body { border: 0; margin: 0; }
425
427
  font: inherit; font-size: var(--type-size-body);
426
428
  outline: none;
427
429
  }
428
- .chip-input-field::placeholder { color: var(--color-subtle-foreground); }
430
+ .chip-input-field::placeholder { color: var(--color-muted-foreground); }
429
431
  .chip-input-list {
430
432
  position: absolute; inset-inline: 0; top: 100%; z-index: 40;
431
433
  margin-block-start: var(--spacing-xs);
432
434
  max-height: 240px; overflow-y: auto;
433
435
  }
434
436
  .chip-input-list .menu-item[aria-selected="true"] { background: var(--color-selected); color: var(--color-selected-foreground); }
437
+ .chip-input-list .menu-item[aria-selected="true"] .muted { color: inherit; }
435
438
  .chip-input-list .menu-item .muted { margin-inline-start: auto; }
436
439
 
437
440
  .chip {
@@ -447,6 +450,9 @@ fieldset.form-body { border: 0; margin: 0; }
447
450
  font-size: var(--type-size-label);
448
451
  line-height: 1;
449
452
  }
453
+ /* A structured chip can scroll without widening its field or shrinking targets. */
454
+ .chip-input .chip:not(.filter-chip) { overflow-x: auto; }
455
+ .chip-input .chip:not(.filter-chip) > * { flex-shrink: 0; }
450
456
  .chip:has(.is-editing) { background: var(--color-card); border-color: var(--color-primary); }
451
457
  .chip[data-state="invalid"] { background: var(--color-danger-surface); border-color: var(--color-danger-border); color: var(--color-danger); }
452
458
  /* The chip reads left to right like a call: what installs it, what it is,
@@ -478,8 +484,8 @@ fieldset.form-body { border: 0; margin: 0; }
478
484
  .chip-option { color: var(--color-foreground); }
479
485
  .chip-value:hover, .chip-option:hover { background: var(--color-accent-surface); }
480
486
  .chip-option-name { color: var(--color-muted-foreground); }
481
- .chip-option-mark { color: var(--color-subtle-foreground); }
482
- .chip-option[data-empty="true"] { color: var(--color-subtle-foreground); }
487
+ .chip-option-mark { color: var(--color-muted-foreground); }
488
+ .chip-option[data-empty="true"] { color: var(--color-muted-foreground); }
483
489
  .chip-value:last-child, .chip-option:last-child {
484
490
  border-start-end-radius: var(--radius-xs);
485
491
  border-end-end-radius: var(--radius-xs);
@@ -749,6 +755,7 @@ table.table { width: 100%; border-collapse: collapse; font-size: var(--type-size
749
755
  .dialog-header { padding: var(--spacing-lg) var(--spacing-lg) 0; display: grid; gap: var(--spacing-2xs); }
750
756
  .dialog-body { padding: var(--spacing-lg); display: grid; gap: var(--spacing-lg); }
751
757
  .dialog-header + .dialog-body { padding-block-start: var(--spacing-md); }
758
+ .dialog-header + .dialog-footer { margin-block-start: var(--spacing-lg); }
752
759
  .dialog-footer { padding: var(--spacing-md) var(--spacing-lg); border-top: 1px solid var(--color-border); display: flex; justify-content: flex-end; gap: var(--spacing-sm); background: var(--color-muted); border-end-start-radius: var(--radius-surface); border-end-end-radius: var(--radius-surface); }
753
760
 
754
761
  /* ===========================================================================
@@ -1051,7 +1058,7 @@ pre code { display: block; overflow-x: auto; padding: 0; background: none; borde
1051
1058
  container scrolls for the corner marks it draws just outside its own frame.
1052
1059
  Height goes on `.logview`; the scroller takes what is left. */
1053
1060
  .logview > .log-scroll { flex: 1 1 auto; min-block-size: 0; overflow: auto; }
1054
- .logview .log-time { color: var(--color-neutral-600); margin-inline-end: var(--spacing-sm); }
1061
+ .logview .log-time { color: var(--color-neutral-500); margin-inline-end: var(--spacing-sm); }
1055
1062
  .logview .log-warn { color: var(--color-warning-on-dark); }
1056
1063
  .logview .log-error{ color: var(--color-danger-on-dark); }
1057
1064
  .logview .log-info { color: var(--color-info-on-dark); }
@@ -1061,18 +1068,186 @@ pre code { display: block; overflow-x: auto; padding: 0; background: none; borde
1061
1068
  .bars i { flex: 1; background: var(--color-border-strong); border-radius: var(--radius-xs); }
1062
1069
  .bars i.on { background: var(--color-primary); }
1063
1070
 
1071
+ /* ===========================================================================
1072
+ Screen layout: list, detail and create screens (Kiso: list-detail, crud)
1073
+ =========================================================================== */
1074
+ /* The filter row on a list screen. Search first and growing, any extra
1075
+ filter after it, and "Clear filters" only once something is filtered. The
1076
+ way to add one more is in the page header, not here: narrowing a list and
1077
+ adding to it are opposite intentions. */
1078
+ .list-filters { display: flex; align-items: center; flex-wrap: wrap; gap: var(--spacing-sm); }
1079
+ .list-filters > .input-group { flex: 1 1 260px; min-width: 0; }
1080
+ /* The select is an accessory to the search, not a second field, so it takes
1081
+ only what its longest option needs. */
1082
+ .list-filters > .select { flex: none; width: auto; min-width: 11rem; }
1083
+ @media (max-width: 560px) {
1084
+ .list-filters > .input-group { flex-basis: 100%; }
1085
+ .list-filters > .select { width: 100%; }
1086
+ }
1087
+
1088
+ /* The detail screen's first row: the title on the left, the lifecycle row on
1089
+ the right. A title with a description is taller than the row, so the row
1090
+ sits at the top, and it wraps under the title when there is no room. */
1091
+ .between:has(> .lifecycle) { align-items: flex-start; flex-wrap: wrap; gap: var(--spacing-lg); }
1092
+
1093
+ /* The detail screen's header row: what it is, what you can do to it, and the
1094
+ one action you do not want to hit by accident. The two clusters sit a
1095
+ normal gap apart, and the destructive action gets a wider one so nothing is
1096
+ ever flush against it. */
1097
+ .lifecycle { display: flex; flex-wrap: wrap; align-items: center; gap: var(--spacing-md); }
1098
+ .lifecycle > .btn-danger-ghost { margin-inline-start: var(--spacing-sm); }
1099
+
1100
+ /* A cluster is one object made of several. The children give up their own
1101
+ border and corners to the frame, and a hairline between them is what says
1102
+ they are separate readings, or separate choices. */
1103
+ .cluster {
1104
+ display: inline-flex; align-items: stretch;
1105
+ height: var(--size-control-sm);
1106
+ border: 1px solid var(--color-border);
1107
+ border-radius: var(--radius-md);
1108
+ overflow: hidden;
1109
+ }
1110
+ /* Status is read, not pressed: no shadow, and each badge keeps the tone
1111
+ surface that carries its meaning. */
1112
+ .cluster-status > .badge {
1113
+ height: auto; border-radius: 0;
1114
+ border-block: 0; border-inline-end: 0;
1115
+ padding-inline: var(--spacing-md);
1116
+ font-size: var(--type-size-label);
1117
+ }
1118
+ /* The tone's own border would double up against the frame's line. */
1119
+ .cluster-status > .badge + .badge { border-inline-start-color: var(--color-border); }
1120
+ /* The verbs are pressed, so the group carries the surface and the shadow
1121
+ each button carried on its own. */
1122
+ .cluster-verbs { background: var(--color-card); box-shadow: var(--shadow-xs); }
1123
+ /* `.cluster` twice, so the flat corners beat the appearance rule that rounds
1124
+ every control and comes later in this file. */
1125
+ .cluster.cluster-verbs > .btn { border: 0; border-radius: 0; box-shadow: none; background: transparent; }
1126
+ .cluster-verbs > .btn + .btn { border-inline-start: 1px solid var(--color-border); }
1127
+ .cluster-verbs > .btn:hover:not(:disabled) { background: var(--color-accent-surface-hover); }
1128
+ /* A greyed verb is the row's normal state (Start is off while the thing
1129
+ runs), so it reads as unavailable without punching a hole in the group. */
1130
+ .cluster-verbs > .btn:disabled { background: var(--color-disabled-surface); color: var(--color-disabled); }
1131
+ /* The focus ring belongs on the group's edge, not on a square inside it. */
1132
+ .cluster-verbs > .btn:focus-visible { outline-offset: -2px; }
1133
+ /* Wrapped onto its own line, the destructive action has no gap left to set
1134
+ it apart, so the verbs take the row instead, and the row goes under the
1135
+ title rather than squeezing beside it. */
1136
+ @media (max-width: 720px) {
1137
+ .lifecycle { flex-basis: 100%; gap: var(--spacing-sm); }
1138
+ .cluster-verbs { width: 100%; }
1139
+ .cluster-verbs > .btn { flex: 1; }
1140
+ .lifecycle > .btn-danger-ghost { margin-inline-start: 0; }
1141
+ }
1142
+
1143
+ /* One card for everything a detail screen holds: its configuration, and
1144
+ whatever it prints. The card takes the height the header row leaves and
1145
+ each panel scrolls inside it, so switching tabs does not resize the page
1146
+ under the cursor. */
1147
+ .detail-tabs { min-width: 0; display: grid; grid-template-rows: minmax(0, 1fr); }
1148
+ .detail-tabs > [data-slot="tabs"] { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; }
1149
+ /* The card does not clip, or its corner marks would go with the overflow.
1150
+ Its bottom corners pass down to the panel and the form instead, so a
1151
+ sticky footer and an edge-to-edge log follow the card's shape. */
1152
+ .detail-tabs > [data-slot="tabs"],
1153
+ .detail-tabs [role="tabpanel"],
1154
+ .detail-tabs [role="tabpanel"] > .form {
1155
+ border-end-start-radius: inherit; border-end-end-radius: inherit;
1156
+ }
1157
+ /* The strip scrolls sideways when the tabs do not fit. A scroller's automatic
1158
+ minimum size is zero, so the list keeps its own height explicitly. */
1159
+ .detail-tabs [role="tablist"] { max-width: 100%; overflow-x: auto; padding-inline: var(--spacing-xl); }
1160
+ .detail-tabs > [data-slot="tabs"] > [role="tablist"] { min-height: min-content; }
1161
+ .detail-tabs [role="tabpanel"] { min-width: 0; min-height: 0; overflow: auto; padding: var(--spacing-xl); }
1162
+ /* A panel with its own scroller, a log or a thread, takes the card out to
1163
+ its edges. */
1164
+ .detail-tabs [role="tabpanel"]:is(.detail-logs, .detail-pane) {
1165
+ display: grid; grid-template-rows: minmax(0, 1fr);
1166
+ overflow: hidden; padding: 0;
1167
+ }
1168
+ .detail-tabs .logview { min-height: 0; border: 0; border-radius: 0; --corner-mark: 0; }
1169
+ .detail-tabs .log-scroll { padding: var(--spacing-lg); }
1170
+ /* A display of our own beats the `hidden` a panel keeps after you leave it. */
1171
+ .detail-tabs [role="tabpanel"][hidden] { display: none; }
1172
+ /* A panel that holds a form gives its padding to the form's body, so a
1173
+ sticky FormActions spans the panel and rests on its bottom edge. */
1174
+ .detail-tabs [role="tabpanel"]:has(> .form) { padding: 0; }
1175
+ .detail-tabs .form-body { padding: var(--spacing-xl); }
1176
+
1177
+ /* A screen that fills the viewport: a detail card, a create card, or a list
1178
+ whose table is the page's last child. The page has to stop growing with
1179
+ its content, or the card and the table have no height to take. A screen
1180
+ opts out with `data-fill="false"` on the card or the `.table-wrap`, and
1181
+ the document scrolls as it did.
1182
+ Below 1024px the sidebar stacks above the content and the document
1183
+ scrolls again, so the cap is lifted with it. */
1184
+ @media (min-width: 1024px) {
1185
+ .app-shell:has(.page > :is(.detail-tabs, .form-page):not([data-fill="false"]), .page > .table-wrap:last-child:not([data-fill="false"])) { height: 100vh; }
1186
+ .app-shell:has(.page > :is(.detail-tabs, .form-page):not([data-fill="false"]), .page > .table-wrap:last-child:not([data-fill="false"])) > main {
1187
+ display: flex; flex-direction: column; min-height: 0;
1188
+ }
1189
+ .app-shell:has(.page > :is(.detail-tabs, .form-page):not([data-fill="false"]), .page > .table-wrap:last-child:not([data-fill="false"])) > main > * { flex: none; }
1190
+ .page:has(> :is(.detail-tabs, .form-page):not([data-fill="false"]), > .table-wrap:last-child:not([data-fill="false"])) { display: flex; flex-direction: column; min-height: 0; }
1191
+ .app-shell > main > .page:has(> :is(.detail-tabs, .form-page):not([data-fill="false"]), > .table-wrap:last-child:not([data-fill="false"])) { flex: 1; }
1192
+ /* `:where`, so the reset stays below the card's and the table's own flex. */
1193
+ .page:where(:has(> :is(.detail-tabs, .form-page):not([data-fill="false"]), > .table-wrap:last-child:not([data-fill="false"]))) > * { flex: none; }
1194
+ .page > :is(.detail-tabs, .form-page):not([data-fill="false"]) { flex: 1; min-height: 0; }
1195
+ /* The create card scrolls its form, so sticky actions rest on its edge. */
1196
+ .page > .form-page:not([data-fill="false"]) { display: grid; grid-template-rows: minmax(0, 1fr); }
1197
+ .page > .form-page:not([data-fill="false"]) > .form { min-height: 0; overflow: auto; }
1198
+ .page > .form-page[data-fill="false"] > .form > .form-actions[data-sticky="true"] { position: static; }
1199
+ /* The rows scroll inside the table, under its sticky header, and the count
1200
+ stays on the bottom edge. A page with more above the table than the
1201
+ viewport holds keeps a usable table and scrolls instead. */
1202
+ .page:has(> .table-wrap:last-child:not([data-fill="false"])) { overflow-y: auto; }
1203
+ .page > .table-wrap:last-child:not([data-fill="false"]) {
1204
+ flex: 1; min-height: calc(var(--size-control-lg) * 6);
1205
+ display: flex; flex-direction: column;
1206
+ }
1207
+ .page > .table-wrap:last-child:not([data-fill="false"]) > .table-scroll { flex: 1; min-height: 0; }
1208
+ .page > .table-wrap:last-child:not([data-fill="false"]) > .table-footer { flex: none; }
1209
+ }
1210
+ @media (max-width: 1023px) {
1211
+ .detail-tabs { grid-template-rows: auto; }
1212
+ /* The document scrolls, so the create form's actions end it in flow rather
1213
+ than float over a card whose marks would not follow them. */
1214
+ .form-page > .form > .form-actions[data-sticky="true"] { position: static; }
1215
+ .detail-tabs [role="tabpanel"] { overflow: visible; }
1216
+ .detail-tabs [role="tabpanel"]:is(.detail-logs, .detail-pane) { height: 60vh; }
1217
+ }
1218
+ @media (max-width: 720px) {
1219
+ .detail-tabs [role="tablist"] { padding-inline: var(--spacing-lg); }
1220
+ .detail-tabs [role="tabpanel"] { padding: var(--spacing-lg); }
1221
+ .detail-tabs .form-body { padding: var(--spacing-lg); }
1222
+ .detail-tabs [role="tabpanel"]:is(.detail-logs, .detail-pane) { padding: 0; }
1223
+ }
1224
+
1225
+ /* A create screen is the edit form in a card of its own. A form wider than a
1226
+ readable measure is only longer lines. The footer's corners follow the
1227
+ card's. From 1024px the card fills the viewport like a detail card, above. */
1228
+ .form-page { max-width: 720px; }
1229
+ .form-page > .form { border-radius: inherit; }
1230
+
1064
1231
  /* ===========================================================================
1065
1232
  Touch targets: the 44px rule applies to coarse pointers only.
1066
1233
  This is the whole reason the desktop UI can be 36px.
1067
1234
  =========================================================================== */
1068
1235
  @media (pointer: coarse) {
1069
- .btn, .input, .select, .nav-item, .menu-item {
1236
+ .btn, .input, .select, .nav-item, .menu-item, .tabs button {
1070
1237
  min-height: var(--size-touch-min);
1071
1238
  }
1072
- .btn-icon { min-width: var(--size-touch-min); }
1239
+ .btn-icon, .tabs button { min-width: var(--size-touch-min); }
1240
+ .pagination { flex-wrap: wrap; }
1241
+ .pagination .btn { min-width: var(--size-touch-min); flex-shrink: 0; }
1242
+ /* Let the frame include the touch-sized buttons and both borders. */
1243
+ .cluster-verbs { height: auto; }
1073
1244
  /* The chip grows with its segments, so the floor goes on the chip and the
1074
1245
  segments stretch to it. */
1075
1246
  .chip, .chip-input-field { min-height: var(--size-touch-min); }
1247
+ button.chip-value, button.chip-option,
1248
+ .chip-value.is-editing, .chip-option.is-editing {
1249
+ min-width: var(--size-touch-min); min-height: var(--size-touch-min);
1250
+ }
1076
1251
  .chip-action { min-width: var(--size-touch-min); height: var(--size-touch-min); }
1077
1252
  }
1078
1253
 
@@ -1299,6 +1474,14 @@ pre code { display: block; overflow-x: auto; padding: 0; background: none; borde
1299
1474
  .splitter { flex: none; width: 1px; background: var(--color-border); cursor: col-resize; position: relative; }
1300
1475
  .splitter::after { content: ""; position: absolute; inset-block: 0; inset-inline: -3px; }
1301
1476
  .splitter:hover, .splitter.dragging { background: var(--color-ring); }
1477
+ @media (pointer: coarse) {
1478
+ /* Reserve the hit area so it cannot cover controls at either pane's edge. */
1479
+ .splitter {
1480
+ margin-inline: calc((var(--size-touch-min) - 1px) / 2);
1481
+ touch-action: none;
1482
+ }
1483
+ .splitter::after { inset-inline: calc((1px - var(--size-touch-min)) / 2); }
1484
+ }
1302
1485
 
1303
1486
  /* Legacy single-series SVG helpers. Interactive metrics use .framed-chart
1304
1487
  below; these selectors remain compatible with existing consumers. */
@@ -1306,7 +1489,7 @@ pre code { display: block; overflow-x: auto; padding: 0; background: none; borde
1306
1489
  .chart .grid { stroke: var(--color-border); stroke-width: 1; }
1307
1490
  .chart .line { fill: none; stroke: currentColor; stroke-width: 1.5; }
1308
1491
  .chart .area { fill: currentColor; fill-opacity: 0.12; }
1309
- .chart-axis { display: flex; justify-content: space-between; font-size: var(--type-size-metadata); color: var(--color-subtle-foreground); margin-block-start: var(--spacing-sm); }
1492
+ .chart-axis { display: flex; justify-content: space-between; font-size: var(--type-size-metadata); color: var(--color-muted-foreground); margin-block-start: var(--spacing-sm); }
1310
1493
 
1311
1494
  /* Sparkline — one metric series at cell size (issue #84). No axis, no grid,
1312
1495
  no legend, no tooltip: the number beside the line carries the precision,
@@ -1340,7 +1523,8 @@ pre code { display: block; overflow-x: auto; padding: 0; background: none; borde
1340
1523
  .meter, .bar-gauge { display: grid; gap: var(--spacing-sm); min-width: 0; }
1341
1524
  .bar-gauge { gap: var(--spacing-md); }
1342
1525
  .meter-label { display: flex; justify-content: space-between; gap: var(--spacing-md); font-size: var(--type-size-label); }
1343
- .meter-label > :last-child { text-align: end; font-variant-numeric: tabular-nums; }
1526
+ .meter-label > :first-child { flex: 1; min-width: 0; overflow-wrap: anywhere; }
1527
+ .meter-label > :last-child { max-width: 50%; overflow-wrap: anywhere; text-align: end; font-variant-numeric: tabular-nums; }
1344
1528
  .meter-track { height: calc(var(--spacing-xs) + var(--spacing-2xs)); border-radius: var(--radius-xs); background-color: var(--color-secondary); overflow: hidden; }
1345
1529
  .meter-track > span { display: block; height: 100%; background: var(--color-secondary-foreground); }
1346
1530
  .disclosure { min-width: 0; }
@@ -1364,6 +1548,7 @@ pre code { display: block; overflow-x: auto; padding: 0; background: none; borde
1364
1548
  .dashboard-panel, .dashboard-panel[data-span="12"] { grid-column: 1; }
1365
1549
  }
1366
1550
  @media (pointer: coarse) {
1551
+ .time-range-trigger { min-height: var(--size-touch-min); }
1367
1552
  .disclosure > summary { min-height: var(--size-touch-min); display: list-item; align-content: center; }
1368
1553
  }
1369
1554
 
@@ -1448,3 +1633,43 @@ button.step-row:focus-visible { outline: 2px solid var(--color-focus); outline-o
1448
1633
  transition: none !important;
1449
1634
  }
1450
1635
  }
1636
+
1637
+ /* FilterInput: continuous typing with editable field/operator/value segments. */
1638
+ .filter-input { display: grid; gap: var(--spacing-sm); min-width: 0; }
1639
+ .filter-box { align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); }
1640
+ .filter-field-input { min-width: 12ch; flex: 1 1 18ch; font-family: var(--font-mono); }
1641
+ .filter-chip { padding-inline: 0 var(--spacing-xs); flex-wrap: wrap; }
1642
+ .filter-segment {
1643
+ display: inline-flex; align-items: center; flex-wrap: wrap; gap: var(--spacing-xs);
1644
+ max-width: 100%; min-width: 0; overflow-wrap: anywhere; white-space: normal;
1645
+ padding: var(--spacing-sm); border: 0; border-inline-end: 1px solid var(--color-border);
1646
+ min-height: var(--size-control-sm); background: transparent; color: var(--color-foreground);
1647
+ font-family: var(--font-mono); font-size: var(--type-size-metadata); cursor: pointer;
1648
+ }
1649
+ .filter-segment:hover { background: var(--color-accent-surface-hover); }
1650
+ .filter-operator { background: var(--color-card); color: var(--color-muted-foreground); }
1651
+ .filter-value { background: var(--color-card); color: var(--color-link); }
1652
+ .filter-list-value { padding: var(--spacing-2xs) var(--spacing-xs); background: var(--color-accent-surface); border-radius: var(--radius-sm); overflow-wrap: anywhere; }
1653
+ .filter-editor { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--spacing-xs); max-width: 100%; min-width: 0; }
1654
+ .filter-segment-input { border: 0; padding: var(--spacing-sm); background: var(--color-card); color: var(--color-foreground); font-family: var(--font-mono); font-size: var(--type-size-metadata); min-height: var(--size-control-sm); min-width: 4ch; max-width: 100%; }
1655
+ .filter-edit-actions, .filter-actions { display: flex; flex-wrap: wrap; gap: var(--spacing-xs); }
1656
+ .filter-join, .filter-boundary {
1657
+ border: 0; padding: var(--spacing-xs); background: transparent; color: var(--color-muted-foreground);
1658
+ font-family: var(--font-mono); font-size: var(--type-size-metadata); min-height: var(--size-control-sm); cursor: pointer;
1659
+ }
1660
+ .filter-join[data-join="OR"] { border-inline: 1px solid var(--color-border-strong); padding-inline: var(--spacing-sm); color: var(--color-link); }
1661
+ .filter-group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: var(--spacing-sm); max-width: 100%; padding: var(--spacing-sm); border: 1px solid var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-accent-surface); }
1662
+ .filter-group .filter-group { background: var(--color-card); }
1663
+ .filter-boundary { display: inline-flex; align-items: center; color: var(--color-link); }
1664
+ .filter-segment:focus-visible, .filter-segment-input:focus-visible, button.filter-boundary:focus-visible, .filter-join:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
1665
+ .filter-input button:disabled, .filter-segment-input:disabled { cursor: default; color: var(--color-disabled); }
1666
+ .filter-suggestions { position: absolute; z-index: 20; inset-inline: 0; top: calc(100% + var(--spacing-xs)); max-height: 20em; overflow: auto; box-shadow: var(--shadow-lg); }
1667
+ .filter-suggestions .menu-item { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--spacing-sm); cursor: pointer; }
1668
+ .filter-suggestions .menu-item[aria-selected="true"] { background: var(--color-selected); color: var(--color-selected-foreground); }
1669
+ .filter-suggestions .menu-item[aria-selected="true"] .muted { color: inherit; }
1670
+ @media (pointer: coarse) {
1671
+ .filter-segment, .filter-join, button.filter-boundary, .filter-segment-input { min-height: var(--size-touch-min); }
1672
+ .filter-segment, .filter-join, button.filter-boundary { min-width: var(--size-touch-min); }
1673
+ }
1674
+
1675
+ .filter-status { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momoi-labs/kiso",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Kiso design-system contracts and generated design tokens",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -125,7 +125,7 @@ export const semanticElevatedSurface: string;
125
125
  export const semanticForeground: string;
126
126
  /** Secondary text, labels. Text-eligible: passes WCAG AA 4.5:1 on background, surface and elevated-surface in both themes. */
127
127
  export const semanticMutedForeground: string;
128
- /** Placeholders, timestamps, hints. Large text and non-essential metadata only (>=3:1). Never body copy. On dark this is dark.450, because dark.500 misses 3:1 on the dark surfaces. */
128
+ /** Large supporting text and non-text graphics only (>=3:1 on their surface). Never small text. On dark this is dark.450, because dark.500 misses 3:1 on the dark surfaces. */
129
129
  export const semanticSubtleForeground: string;
130
130
  /** Dividers and input outlines. Non-text role. */
131
131
  export const semanticBorder: string;
@@ -90,7 +90,7 @@ $semantic-surface: #2d2b28; // Cards, panels, table rows. Surface role.
90
90
  $semantic-elevated-surface: #3a3834; // Menus, popovers, dialogs. Surface role.
91
91
  $semantic-foreground: #eeece9; // Primary text. Text-eligible: passes WCAG AA 4.5:1 on background, surface and elevated-surface in both themes.
92
92
  $semantic-muted-foreground: #aba9a5; // Secondary text, labels. Text-eligible: passes WCAG AA 4.5:1 on background, surface and elevated-surface in both themes.
93
- $semantic-subtle-foreground: #94928e; // Placeholders, timestamps, hints. Large text and non-essential metadata only (>=3:1). Never body copy. On dark this is dark.450, because dark.500 misses 3:1 on the dark surfaces.
93
+ $semantic-subtle-foreground: #94928e; // Large supporting text and non-text graphics only (>=3:1 on their surface). Never small text. On dark this is dark.450, because dark.500 misses 3:1 on the dark surfaces.
94
94
  $semantic-border: #494744; // Dividers and input outlines. Non-text role.
95
95
  $semantic-primary: #684bb5; // Primary fill: primary buttons, solid badges, checked controls, the brand mark. Deep violet in both themes; on dark it is a dedicated fill and is not text-eligible, so ink roles (link, focus) take accent.base instead.
96
96
  $semantic-accent: #ded5fb; // Emphasis and highlight. Text-eligible: passes WCAG AA 4.5:1 on background, surface and elevated-surface in both themes.