trykeyline 0.0.0-stage → 0.1.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/.claude-plugin/marketplace.json +18 -0
- package/LICENSE +37 -0
- package/README.md +117 -2
- package/bin/keyline.mjs +280 -0
- package/package.json +38 -4
- package/plugins/keyline/.claude-plugin/plugin.json +14 -0
- package/plugins/keyline/skills/keyline/SKILL.md +57 -0
- package/plugins/keyline/skills/keyline/commands/audit.md +25 -0
- package/plugins/keyline/skills/keyline/commands/color.md +7 -0
- package/plugins/keyline/skills/keyline/commands/critique.md +43 -0
- package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
- package/plugins/keyline/skills/keyline/commands/init.md +15 -0
- package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
- package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
- package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
- package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
- package/plugins/keyline/skills/keyline/commands/refine.md +29 -0
- package/plugins/keyline/skills/keyline/commands/sync.md +17 -0
- package/plugins/keyline/skills/keyline/commands/type.md +7 -0
- package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
- package/plugins/keyline/skills/keyline/domains/color/guide.md +243 -0
- package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
- package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
- package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +191 -0
- package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
- package/plugins/keyline/skills/keyline/domains/depth/guide.md +255 -0
- package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
- package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
- package/plugins/keyline/skills/keyline/domains/layout/guide.md +284 -0
- package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
- package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
- package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
- package/plugins/keyline/skills/keyline/domains/motion/guide.md +234 -0
- package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
- package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
- package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
- package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
- package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
- package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
- package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
- package/plugins/keyline/skills/keyline/domains/patterns/guide.md +216 -0
- package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +78 -0
- package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
- package/plugins/keyline/skills/keyline/domains/type/guide.md +251 -0
- package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
- package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
- package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
- package/plugins/keyline/skills/keyline/domains/type/type-audit.md +90 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +319 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
- package/plugins/keyline/skills/keyline/glass/color/guide.md +222 -0
- package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
- package/plugins/keyline/skills/keyline/glass/color/theming.md +112 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
- package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
- package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
- package/plugins/keyline/skills/keyline/glass/depth/guide.md +216 -0
- package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
- package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +199 -0
- package/plugins/keyline/skills/keyline/glass/layout/guide.md +258 -0
- package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
- package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +102 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
- package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
- package/plugins/keyline/skills/keyline/glass/motion/guide.md +286 -0
- package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
- package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
- package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
- package/plugins/keyline/skills/keyline/glass/motion/sound.md +192 -0
- package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +414 -0
- package/plugins/keyline/skills/keyline/glass/type/guide.md +222 -0
- package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
- package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
- package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
- package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
- package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
- package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
- package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
- package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
- package/plugins/keyline/skills/keyline/scripts/check.mjs +294 -0
- package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
- package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
- package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
- package/plugins/keyline/skills/keyline/system/detection.md +53 -0
- package/plugins/keyline/skills/keyline/system/guide.md +101 -0
- package/plugins/keyline/skills/keyline/system/presets.md +58 -0
- package/plugins/keyline/skills/keyline/system/roles.md +150 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Actions and forms
|
|
2
|
+
|
|
3
|
+
## Destructive items in a menu
|
|
4
|
+
|
|
5
|
+
Every destroying or session-ending item takes the same place and treatment: last, after a divider, no icon, the danger treatment from the colour guide.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
<Menu>
|
|
9
|
+
<MenuItem onSelect={rename}>Rename</MenuItem>
|
|
10
|
+
<MenuItem onSelect={duplicate}>Duplicate</MenuItem>
|
|
11
|
+
<MenuItem onSelect={openMoveTo}>Move to…</MenuItem>
|
|
12
|
+
<MenuSeparator />
|
|
13
|
+
<MenuItem tone="danger" onSelect={() => openConfirm(row, triggerRef)}>Delete</MenuItem>
|
|
14
|
+
</Menu>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The same holds in the profile menu (Sign out last, alone), in settings (a "Delete workspace" section last on the page, its button in the same danger treatment), and in a bulk-action bar (Delete at the end, set apart).
|
|
18
|
+
|
|
19
|
+
| Item | Destructive or session-ending? |
|
|
20
|
+
|---|---|
|
|
21
|
+
| Delete, Remove from project, Clear history, Revoke access, Leave workspace, Sign out | Yes — one treatment |
|
|
22
|
+
| Archive (restorable), Mark as done, Hide | No — ordinary items, ideally with Undo |
|
|
23
|
+
| Cancel (an unsaved form) | No — a ghost button, not danger |
|
|
24
|
+
|
|
25
|
+
## Proof in proportion to the loss
|
|
26
|
+
|
|
27
|
+
| Loss | Ask for | Example |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| Reversible | Nothing; Undo in a toast for 5–8s | Archive a task, move a card, remove a tag |
|
|
30
|
+
| One item, irreversible | A dialog; the button repeats the verb | Delete a note |
|
|
31
|
+
| Many items, or someone else's work | A dialog that counts what goes ("48 tasks, 12 notes") | Delete a folder |
|
|
32
|
+
| An account, a project, a workspace | Type a word to confirm | Delete project, delete account |
|
|
33
|
+
|
|
34
|
+
Escalate on what is lost, not on how serious the screen looks: a bulk delete of 200 rows deserves more than a delete of one project with nothing in it.
|
|
35
|
+
|
|
36
|
+
## The type-to-confirm dialog
|
|
37
|
+
|
|
38
|
+
One component, used for every large delete. It is the system's dialog with different contents, never a bespoke surface (depth law 1).
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
export function ConfirmDestroy({ open, onOpenChange, title, consequence, word = "DELETE",
|
|
42
|
+
action, pendingLabel, onConfirm, returnFocusTo }) {
|
|
43
|
+
const [typed, setTyped] = useState("");
|
|
44
|
+
const [state, setState] = useState<"idle" | "pending" | "error">("idle");
|
|
45
|
+
const [error, setError] = useState("");
|
|
46
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
47
|
+
const matches = typed.trim().toLowerCase() === word.toLowerCase();
|
|
48
|
+
|
|
49
|
+
async function confirm(e: React.FormEvent) {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
if (!matches || state === "pending") return;
|
|
52
|
+
setState("pending");
|
|
53
|
+
try {
|
|
54
|
+
await onConfirm();
|
|
55
|
+
onOpenChange(false);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
setState("error");
|
|
58
|
+
setError(messageFor(err)); // words, not a code
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Dialog
|
|
64
|
+
open={open}
|
|
65
|
+
onOpenChange={(next) => state !== "pending" && onOpenChange(next)}
|
|
66
|
+
initialFocus={inputRef}
|
|
67
|
+
finalFocus={returnFocusTo} /* the row or menu item that opened it */
|
|
68
|
+
>
|
|
69
|
+
<form onSubmit={confirm}>
|
|
70
|
+
<DialogTitle>{title}</DialogTitle>
|
|
71
|
+
<p>{consequence}</p>
|
|
72
|
+
<label htmlFor="confirm-word">Type {word} to confirm</label>
|
|
73
|
+
<input id="confirm-word" ref={inputRef} value={typed} autoComplete="off"
|
|
74
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
75
|
+
aria-describedby={state === "error" ? "confirm-error" : undefined} />
|
|
76
|
+
{state === "error" && <p id="confirm-error" role="alert">{error}</p>}
|
|
77
|
+
<DialogFooter>
|
|
78
|
+
<Button variant="ghost" type="button" onClick={() => onOpenChange(false)}
|
|
79
|
+
disabled={state === "pending"}>Cancel</Button>
|
|
80
|
+
<Button tone="danger" type="submit" disabled={!matches || state === "pending"}>
|
|
81
|
+
{state === "pending" ? pendingLabel : action}
|
|
82
|
+
</Button>
|
|
83
|
+
</DialogFooter>
|
|
84
|
+
</form>
|
|
85
|
+
</Dialog>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- **The input takes focus, not Cancel.** The typed word is the guard; focusing it saves a click without weakening it.
|
|
91
|
+
- **Case-insensitive**, trimmed: the reader proves intent, not typing accuracy.
|
|
92
|
+
- **Held open while pending**: Escape and the scrim do nothing until it settles, so a failure has somewhere to be reported.
|
|
93
|
+
- **Focus returns to what opened it**, the row's neighbour if the row is gone — depth law 12, with the code in `domains/depth/elevation-recipes.md`.
|
|
94
|
+
|
|
95
|
+
## Undo instead of confirm
|
|
96
|
+
|
|
97
|
+
For anything reversible, act at once and offer Undo:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
async function archive(task: Task) {
|
|
101
|
+
const before = store.snapshot(task.id);
|
|
102
|
+
store.archive(task.id);
|
|
103
|
+
toast("Task archived", { action: { label: "Undo", onClick: () => store.restore(before) } });
|
|
104
|
+
try { await api.archive(task.id); }
|
|
105
|
+
catch { store.restore(before); toast.error("Couldn't archive task", { action: retry(() => archive(task)) }); }
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A confirm dialog in front of every reversible action trains the reader to click through dialogs, which is how the dangerous one gets clicked through too.
|
|
110
|
+
|
|
111
|
+
## Field anatomy
|
|
112
|
+
|
|
113
|
+
Label above, control, hint below; an error takes the hint's place rather than adding a line.
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
<div className="field">
|
|
117
|
+
<label htmlFor={id}>{label}</label>
|
|
118
|
+
<Control id={id} aria-invalid={!!error || undefined}
|
|
119
|
+
aria-describedby={error || hint ? `${id}-note` : undefined} />
|
|
120
|
+
{(error || hint) && (
|
|
121
|
+
<p id={`${id}-note`} className={error ? "field-error" : "field-hint"}>
|
|
122
|
+
{error ?? hint}
|
|
123
|
+
</p>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Reserve the note's line height when a field has a hint, so the swap never moves the field below. Validate on blur and on submit, not on every keystroke; clear a field's error as soon as its value changes.
|
|
129
|
+
|
|
130
|
+
**A form-level failure** — the server refused, the network dropped — is one line above the primary button, with an icon and `role="alert"`, saying what to do next. The fields keep what the reader typed.
|
|
131
|
+
|
|
132
|
+
## The deciding field first
|
|
133
|
+
|
|
134
|
+
Order a form by dependency, not by importance:
|
|
135
|
+
|
|
136
|
+
| Instead of | Do |
|
|
137
|
+
|---|---|
|
|
138
|
+
| Description, then Category | Category (full width), then the fields it shapes, then Description |
|
|
139
|
+
| Free text, then "Is this private?" | Private or shared first, if it changes who can be mentioned |
|
|
140
|
+
| Every field shown, half disabled | Fields appear once the deciding answer allows them, in place, without reflowing what's above |
|
|
141
|
+
|
|
142
|
+
The deciding field is full width because it is the one question every reader answers; the rest can sit in pairs.
|
|
143
|
+
|
|
144
|
+
## The sign-in screen
|
|
145
|
+
|
|
146
|
+
| Region | Holds |
|
|
147
|
+
|---|---|
|
|
148
|
+
| Form column, about 360px, unboxed | Title ("Sign in"), one muted line, fields, the one brand button, the secondary path as a link ("Create an account") |
|
|
149
|
+
| Art panel, from a wide breakpoint | The depth guide's art panel on the other half (`domains/depth/elevation-recipes.md`); moving only if the auth routes are an `expressive` area in `## Profile`, otherwise one still frame |
|
|
150
|
+
| Phone | The form alone; the art is decoration and goes first |
|
|
151
|
+
|
|
152
|
+
- **No card around the form.** The page is the surface; a card on an otherwise empty page is a box around nothing.
|
|
153
|
+
- **One primary button**, reading the verb: "Sign in", "Create account", "Send link".
|
|
154
|
+
- **Errors**: field errors replace hints; "Wrong email or password" is a form-level line, never a field error that tells an attacker which half was right.
|
|
155
|
+
- **Password managers** need `autocomplete="email"` / `"current-password"` / `"new-password"` and real `<form>` submission.
|
|
156
|
+
- The panel's build and theme scope are the depth guide's art-panel recipe (`domains/depth/elevation-recipes.md`); its motion is the motion guide's ambient class, with a still frame under reduced motion; inputs at 16px on phones, so the browser doesn't zoom, are the type guide.
|
|
157
|
+
|
|
158
|
+
## Pending states by how long the wait is
|
|
159
|
+
|
|
160
|
+
| Wait | The reader sees | Where |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| Under 100ms | The control's own press state | On the control |
|
|
163
|
+
| ≈ 400ms – 1s | The label turns to its progressive form ("Save" → "Saving…"), width held, the control disabled against a second press | In the button |
|
|
164
|
+
| 1s – 10s | A skeleton of what's coming: the rows, the panel's shape, never a centred spinner over a blank region | In the region that will change |
|
|
165
|
+
| Over 10s | Determinate progress — "Importing 340 of 1,200" — with Cancel, and a notice when it's done if the reader has left | In the region, then a toast off screen |
|
|
166
|
+
|
|
167
|
+
Show the pending state only once the threshold passes, so a fast response never flashes it:
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
function usePending(pending: boolean, after = 400) {
|
|
171
|
+
const [shown, setShown] = useState(false);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!pending) return setShown(false);
|
|
174
|
+
const t = setTimeout(() => setShown(true), after);
|
|
175
|
+
return () => clearTimeout(t);
|
|
176
|
+
}, [pending, after]);
|
|
177
|
+
return shown;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// <button disabled={saving} style={{ minWidth }}>{usePending(saving) ? "Saving…" : "Save"}</button>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
A skeleton, once shown, stays for at least ≈ 300ms, or a response that lands just after the threshold makes the region flicker twice.
|
|
184
|
+
|
|
185
|
+
## Forgiving inputs
|
|
186
|
+
|
|
187
|
+
Normalise on blur and on submit, and keep what was typed visible until then:
|
|
188
|
+
|
|
189
|
+
| Field | Accept | Store |
|
|
190
|
+
|---|---|---|
|
|
191
|
+
| Email | Capitals, leading and trailing spaces | Trimmed, domain lower-cased |
|
|
192
|
+
| Phone | Spaces, dashes, brackets, a leading `+` or `00` | E.164 (`+447700900123`) |
|
|
193
|
+
| Card, IBAN, codes | Spaces and dashes, pasted in any grouping | Digits only; displayed back in groups of four |
|
|
194
|
+
| Date | "12 Mar", "12/3", "tomorrow", a picker | ISO date; displayed in the reader's locale |
|
|
195
|
+
| Amount | "1,200", "1200.00", a currency symbol | A number in minor units |
|
|
196
|
+
|
|
197
|
+
`inputmode` and `autocomplete` do half the work — `inputmode="numeric"` for codes, `"email"`, `"tel"` — so the right keyboard arrives and the browser fills what it knows. An error names the fix ("Add the year"), not the rule broken ("Invalid format").
|
|
198
|
+
|
|
199
|
+
## Progress through a flow, and its end
|
|
200
|
+
|
|
201
|
+
- **Show position and what's left**: "Step 2 of 3" or a short list of named steps, the current one marked, earlier ones ticked and revisitable.
|
|
202
|
+
- **Start with credit**: a setup checklist opens with the step the reader just finished already ticked.
|
|
203
|
+
- **No step counter for one screen**, and none that changes length mid-flow; if a branch adds steps, show the new count when the branch is chosen.
|
|
204
|
+
- **The end state** names what happened in the product's nouns ("Riverside project created"), shows the thing itself or a link to it, and offers the next action ("Invite your team"). It is a screen, not a toast that leaves the reader on the last form.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Agents and live states
|
|
2
|
+
|
|
3
|
+
Two patterns for output the reader didn't write and can't fully predict: an agent taking actions, and text arriving as it's produced. Both fail the same way — they ask for trust instead of showing their work.
|
|
4
|
+
|
|
5
|
+
## The tool-call card
|
|
6
|
+
|
|
7
|
+
Every tool call is one card in the conversation, in the order it happened.
|
|
8
|
+
|
|
9
|
+
| Part | Holds |
|
|
10
|
+
|---|---|
|
|
11
|
+
| Header row | Status glyph · tool name in sentence case ("Search tasks", not `search_tasks`) · a one-line summary of the arguments ("Overdue, Riverside") · duration once done |
|
|
12
|
+
| Arguments | Key–value pairs with labels from the glossary; long values truncated with "Show all" |
|
|
13
|
+
| Result | A summary first ("12 tasks"), the raw result behind a disclosure |
|
|
14
|
+
| Approval | Only when the step needs one — inline, under the card |
|
|
15
|
+
|
|
16
|
+
**Open or closed.** A running step is open, so the reader can watch it; a finished one collapses to its header; a failed one stays open with the error in words and a Retry where retrying is safe. The reader's own open or closed choice wins over all three until the conversation reloads.
|
|
17
|
+
|
|
18
|
+
**Status shape, not just colour.** The glyph changes shape between running, done, failed and waiting for approval (shape carries state, colour confirms it: the colour guide); a spinner inside the glyph is fine, a spinner instead of the card is not.
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
<details open={state !== "done"} className="tool-call">
|
|
22
|
+
<summary>
|
|
23
|
+
<StatusGlyph state={state} />
|
|
24
|
+
<span className="tool-name">{label(tool.name)}</span>
|
|
25
|
+
<span className="tool-args">{summarise(tool.args)}</span>
|
|
26
|
+
{state === "done" && <time>{formatDuration(ms)}</time>}
|
|
27
|
+
</summary>
|
|
28
|
+
<ArgsList args={tool.args} />
|
|
29
|
+
<ToolResult result={result} />
|
|
30
|
+
</details>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A native `<details>` keeps the content in the page for find-in-page and copy (layout law 11).
|
|
34
|
+
|
|
35
|
+
## Inline approvals
|
|
36
|
+
|
|
37
|
+
A step that changes something outside the conversation — sends a message, edits records, spends money — waits for approval under its own card:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
┌ ◌ Send email · to 3 members ─────────────────────┐
|
|
41
|
+
│ Subject Site walk moved to Thursday │
|
|
42
|
+
│ To Sam, Priya, Jo │
|
|
43
|
+
└───────────────────────────────────────────────────┘
|
|
44
|
+
[Send email] [Don't send]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **The approve button names the action** and takes the brand action colour; reject is a muted ghost button. Not "Approve" and "Reject": the reader should be able to read the button alone.
|
|
48
|
+
- **Never a modal.** The card above it, and the turns above that, are the evidence for the decision; a modal covers them.
|
|
49
|
+
- **Never focused by default**, and never on Enter from the composer: an approval is a decision, not a keystroke the reader was already making.
|
|
50
|
+
- **After the answer**, the buttons give way to a line saying what was decided ("Sent", "Not sent"), so the record stays readable.
|
|
51
|
+
- **Several pending steps** each keep their own buttons; an "Approve all" may sit above them only when every step is the same kind of action.
|
|
52
|
+
|
|
53
|
+
## Conversation layout
|
|
54
|
+
|
|
55
|
+
- **No avatar per message.** Two speakers don't need faces to be told apart.
|
|
56
|
+
- **The reader's turns** sit right-aligned, on a light tint, at most about two-thirds of the panel's width.
|
|
57
|
+
- **The agent's turns** run full width with no fill — they are the content, and they hold tables, cards and code that need the width.
|
|
58
|
+
- **AI-made content elsewhere** in the product — a summary on a record, a suggested title — carries one small mark in brand beside it, not a robot face or a coloured block (the colour guide).
|
|
59
|
+
- **The panel is non-modal**: a side panel or sheet the reader can work beside, keyboard-reachable, closed by Escape when focus is inside it.
|
|
60
|
+
- **The composer**: Enter sends, Shift+Enter adds a line; the send button is disabled while empty and becomes Stop while the agent is working.
|
|
61
|
+
|
|
62
|
+
## Live text
|
|
63
|
+
|
|
64
|
+
A live transcript or a streaming answer has two kinds of text, and they must look different:
|
|
65
|
+
|
|
66
|
+
| Part | Treatment |
|
|
67
|
+
|---|---|
|
|
68
|
+
| Committed segments | Ink, final; never rewritten |
|
|
69
|
+
| The phrase still forming | Muted, with a brand caret at its end |
|
|
70
|
+
| Speaker or time markers | Meta tier, at each change, not on every line |
|
|
71
|
+
|
|
72
|
+
**Following the bottom.** Follow while the reader is at the bottom; stop the moment they scroll up; show "Jump to latest" until they return:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
function useFollowBottom(el: HTMLElement | null, deps: unknown[]) {
|
|
76
|
+
const atBottom = useRef(true);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!el) return;
|
|
79
|
+
const onScroll = () => {
|
|
80
|
+
atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
|
81
|
+
};
|
|
82
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
83
|
+
return () => el.removeEventListener("scroll", onScroll);
|
|
84
|
+
}, [el]);
|
|
85
|
+
useLayoutEffect(() => {
|
|
86
|
+
if (el && atBottom.current) el.scrollTop = el.scrollHeight;
|
|
87
|
+
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
|
|
88
|
+
return atBottom;
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**The top fade** says more is above, and costs nothing to layout:
|
|
93
|
+
|
|
94
|
+
```css
|
|
95
|
+
.live-scroll {
|
|
96
|
+
mask-image: linear-gradient(to bottom, transparent 0, #000 32px);
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Announcing.** Put committed segments in a polite live region, one segment at a time; keep the forming phrase out of it, or a screen reader re-reads every word as it changes. A visually hidden region beside the visible text is often simpler than making the visible list live.
|
|
101
|
+
|
|
102
|
+
## Paused, stopped, failed
|
|
103
|
+
|
|
104
|
+
| State | Shows | Offers |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| Live | Moving level bars or the one "working" signal | Pause; on hover and focus the live control becomes Stop |
|
|
107
|
+
| Paused | Everything still; the forming phrase committed or dropped | View (the text so far), Copy, Resume |
|
|
108
|
+
| Stopped | The full text, final | Copy, and whatever the product does next with it |
|
|
109
|
+
| Failed | What was captured, kept | Retry, and the captured text never lost |
|
|
110
|
+
|
|
111
|
+
What moves while live and stops while paused is motion law 1; what colour a live state takes is the colour guide's *Live is not danger*.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Collections and dashboards
|
|
2
|
+
|
|
3
|
+
## Which view first
|
|
4
|
+
|
|
5
|
+
| The items… | Primary view | Second view |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| Move through stages (to do → in progress → review → done) | Board: columns in the order work moves | List, grouped by status |
|
|
8
|
+
| Happen at a time (visits, meetings, deadlines) | Week board with Week, 3 days and Day spans | List, grouped by day |
|
|
9
|
+
| Are compared on many fields (members, invoices) | Table | — |
|
|
10
|
+
| Are browsed by place (folders, projects) | Grid or list of places | — |
|
|
11
|
+
|
|
12
|
+
**Board columns** run left to right in workflow order, each headed by its status glyph, name and count; the done column sits last and may collapse. A column that work skips is still shown, so the board reads the same every day.
|
|
13
|
+
|
|
14
|
+
**Both views share everything but the container**: data, filters, sort, row rendering and the row menu (layout law 8). A filter set on the board is still set on the list.
|
|
15
|
+
|
|
16
|
+
## Remembering the view
|
|
17
|
+
|
|
18
|
+
On a working page the reader returns to daily, the view and span are remembered **per page**, not globally — the reader set the tasks board deliberately and the meetings list deliberately — with the switch always visible in the page header (layout law 9 owns the rule; this is the code).
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
const key = (page: string) => `view:${page}`;
|
|
22
|
+
|
|
23
|
+
export function useRememberedView<T extends string>(page: string, fallback: T, opts?: { pinned?: boolean }) {
|
|
24
|
+
const [view, setView] = useState<T>(() => {
|
|
25
|
+
if (opts?.pinned) return fallback; // the default is the point
|
|
26
|
+
try { return (localStorage.getItem(key(page)) as T) ?? fallback; } catch { return fallback; }
|
|
27
|
+
});
|
|
28
|
+
const choose = (next: T) => {
|
|
29
|
+
setView(next);
|
|
30
|
+
if (!opts?.pinned) try { localStorage.setItem(key(page), next); } catch {}
|
|
31
|
+
};
|
|
32
|
+
return [view, choose] as const;
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Pinned pages** are those whose job is one view: folders open on folders, a calendar opens on this week. The reader can still switch for a visit; it just isn't remembered. List the remembered and pinned pages in `DESIGN.md` under `## Patterns`.
|
|
37
|
+
|
|
38
|
+
## Optimistic moves with rollback
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
let seq = 0;
|
|
42
|
+
|
|
43
|
+
async function moveCard(id: string, to: { column: string; index: number }) {
|
|
44
|
+
const mine = ++seq;
|
|
45
|
+
const before = board.positionOf(id);
|
|
46
|
+
board.move(id, to); // now
|
|
47
|
+
try {
|
|
48
|
+
await api.move(id, to);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (mine !== seq && board.positionOf(id) !== to) return; // a later move owns this card
|
|
51
|
+
board.move(id, before); // visibly put it back
|
|
52
|
+
toast.error("Couldn't move task", { action: { label: "Retry", onClick: () => moveCard(id, to) } });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- **Animate the rollback** with the same travel as the move (the motion guide), so the reader sees the card go back rather than finding it there later.
|
|
58
|
+
- **Keep sequence**: if two moves race, only the newest one's failure rolls back.
|
|
59
|
+
- **Don't disable the board while saving.** Saving is background work.
|
|
60
|
+
- **Never optimistic**: deletes without Undo, sends, payments, invitations — anything another person sees at once or the server decides.
|
|
61
|
+
|
|
62
|
+
## The keyboard map
|
|
63
|
+
|
|
64
|
+
| Key | Does | Where |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| ⌘K / Ctrl+K | Command palette: go to, create, run any action by name | Everywhere |
|
|
67
|
+
| J / K | Next / previous row or card | Lists and boards |
|
|
68
|
+
| Enter / O | Open the focused item | Lists and boards |
|
|
69
|
+
| F | Open filters | Lists and boards |
|
|
70
|
+
| C | Create in the current collection | Lists and boards |
|
|
71
|
+
| Enter | Send | Composer |
|
|
72
|
+
| Shift+Enter | New line | Composer |
|
|
73
|
+
| Escape | Close the innermost layer only | Everywhere |
|
|
74
|
+
| ? | Show the shortcut list | Everywhere |
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const layers: Array<() => void> = []; // push on open, pop on close
|
|
78
|
+
|
|
79
|
+
window.addEventListener("keydown", (e) => {
|
|
80
|
+
const t = e.target as HTMLElement;
|
|
81
|
+
const typing = t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName);
|
|
82
|
+
if (e.key === "Escape" && layers.length) { e.preventDefault(); layers.pop()!(); return; }
|
|
83
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); openPalette(); return; }
|
|
84
|
+
if (typing || e.metaKey || e.ctrlKey || e.altKey) return; // single keys never fire while typing
|
|
85
|
+
shortcuts[e.key]?.(e);
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Every drag has a keyboard path**: the row menu's "Move to…" opens a list of columns or dates; on a focused card, a shortcut can open it directly. **Shortcuts are shown where the action lives** — beside the menu item, in the tooltip, in the palette — from the one map in `DESIGN.md`, so the three never disagree.
|
|
90
|
+
|
|
91
|
+
## Launchers and toolbars
|
|
92
|
+
|
|
93
|
+
**A launcher asks the fewest questions the task has.** A record button that opens a form of six fields makes the most frequent action the slowest. Ask the one question that routes the result — which project, or a personal note — and default everything else, editable afterwards.
|
|
94
|
+
|
|
95
|
+
**Toolbar filters are icons with tooltips**, each with its active state shown on the icon and a count when it holds several values. Text on every filter crowds the bar and pushes the page's own actions off narrow screens. The active filters show as removable chips under the bar, where they also explain an empty result.
|
|
96
|
+
|
|
97
|
+
**Long admin lists** group into collapsible bars, closed by default:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
▸ Admins 3 of 12 with access
|
|
101
|
+
▸ Members 9 of 12 with access
|
|
102
|
+
▸ Guests 0 of 4
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Each bar's summary answers what the reader would open it to check. A search opens every group it matches and highlights the match; clearing the search restores what was open before.
|
|
106
|
+
|
|
107
|
+
## Dashboards
|
|
108
|
+
|
|
109
|
+
**The order**: overview (where things stand: a few figures), trends (which way they're moving: one or two charts over a sensible window), action items (what needs the reader: a short list, each item one click from done). Anything that answers none of the three goes — a recent-activity feed is the usual casualty, unless the product or its brief requires it; then it stays, built to earn its place (`commands/refine.md`).
|
|
110
|
+
|
|
111
|
+
**Wireframe first**, in boxes and words, and share it before building. Questions to settle on the wireframe:
|
|
112
|
+
|
|
113
|
+
- Which three to five figures, and what each one counts — its label names the noun.
|
|
114
|
+
- Which figures report a problem (and so take a status colour while they do) and which are plain counts.
|
|
115
|
+
- What the reader does next from each action item.
|
|
116
|
+
- What the page shows on a new account with no data — usually the action items section offering the first action.
|
|
117
|
+
|
|
118
|
+
**A long page is fine.** Hiding action items behind tabs to keep the page short moves the cost to the reader.
|
|
119
|
+
|
|
120
|
+
**A scoped overview reuses the global pipeline**:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// one pipeline, two scopes
|
|
124
|
+
const overview = (sources: Source[]) => buildOverview(sources);
|
|
125
|
+
const global = overview(allSources);
|
|
126
|
+
const project = overview(allSources.filter((s) => s.projectId === id));
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Two queries for one view drift: a filter fixed in one, a status added to the other, and the project's overdue count stops matching the global one for the same project.
|
|
130
|
+
|
|
131
|
+
**Figures that filter.** On a list page, the figures under the header can each apply their filter to the table below. They are then controls: focusable, `aria-pressed` when active, no hover lift (the motion guide), laid out as one hairline-parted row (layout law 3).
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# UI copy
|
|
2
|
+
|
|
3
|
+
Copy in a product is interface, not prose: it is scanned in passing, compared across screens, and read again a thousand times. Each rule below makes a label faster to recognise the second time.
|
|
4
|
+
|
|
5
|
+
## Labels
|
|
6
|
+
|
|
7
|
+
| Instead of | Write | Why |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Record a voice note | Record | The surrounding screen already says what is recorded |
|
|
10
|
+
| Request a feature | Help & feedback | The item also takes questions and bug reports; a label covers everything behind it |
|
|
11
|
+
| Submit | Send · Save changes · Create project | Say what happens, not that a form was posted |
|
|
12
|
+
| OK · Yes | Delete project · Sign out | The confirm button repeats the dialog's verb, so it reads correctly without the question |
|
|
13
|
+
| Click here to view details | View details · or make the row itself the link | "Click here" carries no information out of context |
|
|
14
|
+
| Are you sure you want to delete? | Delete "Riverside"? | Name the thing, drop the hedge |
|
|
15
|
+
| Manage Team Members | Members | Sentence case; the verb is implied by the page |
|
|
16
|
+
| NEW TASK | New task | Uppercase strips word shapes and reads as shouting |
|
|
17
|
+
|
|
18
|
+
**Verb first, object only when needed.** One primary action on a screen needs no object ("Save"). Two verbs that could be confused do ("Save draft", "Publish"). A destructive button always takes its object ("Delete project"), because the object is what's lost.
|
|
19
|
+
|
|
20
|
+
**Labels in menus are nouns or verbs, not both styles at once.** A menu of "Rename", "Duplicate", "Move to…", "Delete" is all verbs; "Settings", "Members", "Billing" is all places. An ellipsis means the item asks for more before acting.
|
|
21
|
+
|
|
22
|
+
**Tooltips on icon buttons say the same words a text button would**, plus the shortcut: "Filter F".
|
|
23
|
+
|
|
24
|
+
## Sentence case, including generated labels
|
|
25
|
+
|
|
26
|
+
Every label, title, column header, tab, field name and toast is sentence case. Keep acronyms, proper nouns and third-party product names as written ("Export to CSV", "Connect GitHub").
|
|
27
|
+
|
|
28
|
+
Labels assembled from keys need a converter with an exception list, or the first acronym breaks it:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const KEEP = new Set(["ID", "URL", "API", "CSV", "PDF", "AI", "SSO"]);
|
|
32
|
+
|
|
33
|
+
export function labelFromKey(key: string): string {
|
|
34
|
+
const words = key
|
|
35
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
36
|
+
.replace(/[_-]+/g, " ")
|
|
37
|
+
.trim()
|
|
38
|
+
.split(/\s+/);
|
|
39
|
+
return words
|
|
40
|
+
.map((w, i) => {
|
|
41
|
+
const upper = w.toUpperCase();
|
|
42
|
+
if (KEEP.has(upper)) return upper;
|
|
43
|
+
const lower = w.toLowerCase();
|
|
44
|
+
return i === 0 ? lower[0].toUpperCase() + lower.slice(1) : lower;
|
|
45
|
+
})
|
|
46
|
+
.join(" ");
|
|
47
|
+
}
|
|
48
|
+
// dueDate → "Due date"; project_id → "Project ID"; AI_summary → "AI summary"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Prefer a hand-written label map for anything the reader sees often; the converter is the fallback for long-tail keys, tool names and imported fields.
|
|
52
|
+
|
|
53
|
+
## Row second lines
|
|
54
|
+
|
|
55
|
+
The formula is **`{type} from {origin}`**, in the product's nouns:
|
|
56
|
+
|
|
57
|
+
| Instead of | Write |
|
|
58
|
+
|---|---|
|
|
59
|
+
| Issue added to Riverside | Task from Riverside |
|
|
60
|
+
| Created by voice note on 3 Mar | Note from site walk |
|
|
61
|
+
| New item in Inbox | Request from Northgate |
|
|
62
|
+
| Riverside · Task · Open · 2h ago · Sam | Task from Riverside — with status as a glyph, time and owner in their own slots |
|
|
63
|
+
|
|
64
|
+
- **Type** is the kind of thing, from the product's glossary. If the list holds one type only, drop it: "From Riverside".
|
|
65
|
+
- **Origin** is where it came from or lives: a project, a meeting, a person, a source. Pick the one the reader sorts by.
|
|
66
|
+
- **No verbs of history** ("added", "created", "moved"). They describe one moment, and the moment is already in the time slot.
|
|
67
|
+
- **No status in the sentence.** Status has a glyph and a colour (the colour guide); words would say it twice.
|
|
68
|
+
|
|
69
|
+
## The glossary
|
|
70
|
+
|
|
71
|
+
One noun per kind of thing, everywhere: navigation, page titles, row lines, toasts, empty states, buttons. Record it in `DESIGN.md`:
|
|
72
|
+
|
|
73
|
+
```markdown
|
|
74
|
+
## Patterns
|
|
75
|
+
|
|
76
|
+
**Nouns** — task (never "issue", "item" or "action"); note (a recording once processed); project; member (never "user" in UI).
|
|
77
|
+
**Row second line** — `{type} from {origin}`; origin is the project, or "Personal" for notes outside one.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A synonym in one corner of the product reads as a second kind of thing. When a new screen needs a word, look it up first.
|
|
81
|
+
|
|
82
|
+
## Messages
|
|
83
|
+
|
|
84
|
+
**Toasts** say what happened, then offer the one thing to do about it:
|
|
85
|
+
|
|
86
|
+
| Case | Write |
|
|
87
|
+
|---|---|
|
|
88
|
+
| Undo window | Task moved to Done · Undo |
|
|
89
|
+
| Failure, retryable | Couldn't move task — Retry |
|
|
90
|
+
| Failure, needs the reader | Couldn't save: the project was archived · View project |
|
|
91
|
+
| Result off screen | Note added to Riverside · Open |
|
|
92
|
+
|
|
93
|
+
Never "Successfully …" (the outcome is the message), never an exclamation mark, never an error code without words. A move the reader watched happen needs no toast.
|
|
94
|
+
|
|
95
|
+
**Errors** say what to do, not what went wrong inside: "Enter a date after the start date", not "Invalid date range". Field errors replace the field's hint (`domains/patterns/actions-and-forms.md`).
|
|
96
|
+
|
|
97
|
+
**Empty states** — one line on what goes here, and the action that makes the first one:
|
|
98
|
+
|
|
99
|
+
| Case | Line | Action |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| New, nothing yet | Tasks from your notes and meetings land here. | New task |
|
|
102
|
+
| Filtered to nothing | No tasks match "Overdue" and "Assigned to me". | Clear filters |
|
|
103
|
+
| Search with no results | Nothing matches "riverbank". | Search all projects |
|
|
104
|
+
|
|
105
|
+
**Confirmations** — the title asks with the thing's name; the body says what is lost in plain words; the button repeats the verb:
|
|
106
|
+
|
|
107
|
+
> **Delete "Riverside"?**
|
|
108
|
+
> Its 48 tasks, 12 notes and every file attached to them will be deleted for everyone. This can't be undone.
|
|
109
|
+
> [Cancel] [Delete project]
|
|
110
|
+
|
|
111
|
+
## Calls to action, links and sign-ups
|
|
112
|
+
|
|
113
|
+
On a landing page or an article the words are most of the interface (law 14):
|
|
114
|
+
|
|
115
|
+
| Instead of | Write | Why |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| Get started · beside Start free | Start free · Book a demo | Each call to action names what happens next, so two can't be mistaken for each other |
|
|
118
|
+
| Learn more · Click here | Pricing · Read the case study | Link text names its destination, and still does in a list of links read out of context |
|
|
119
|
+
| An email field labelled only by "you@example.com" | "Email" above the field; "One essay a month. Unsubscribe any time." below it | A placeholder vanishes on typing; the line says what arrives and how often |
|
|
120
|
+
|
|
121
|
+
## Numbers in copy
|
|
122
|
+
|
|
123
|
+
Counts beside a noun agree with it ("1 task", "3 tasks", "No tasks" — never "0 task(s)"). Use the platform's plural rules (`Intl.PluralRules`) once there is more than one language. A count that reports a problem takes its status colour; a plain count stays in ink (the colour guide).
|