@design-ai/cli 4.57.0 → 4.59.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/plugin.json +1 -1
- package/CHANGELOG.md +59 -0
- package/README.ko.md +5 -4
- package/README.md +5 -4
- package/cli/commands/route.mjs +13 -1
- package/cli/lib/recall.mjs +2 -2
- package/cli/lib/route.mjs +62 -5
- package/cli/lib/search-ranked.mjs +33 -1
- package/cli/sdk/check-adapter.mjs +36 -0
- package/cli/sdk/index.mjs +22 -0
- package/cli/sdk/pack-adapter.mjs +55 -0
- package/cli/sdk/prompt-adapter.mjs +49 -0
- package/cli/sdk/recall-adapter.mjs +39 -0
- package/cli/sdk/route-adapter.mjs +45 -0
- package/cli/sdk/search-adapter.mjs +55 -0
- package/cli/sdk/validate.mjs +48 -0
- package/cli/sdk/version-adapter.mjs +17 -0
- package/docs/AGENT-SDK.md +98 -0
- package/docs/ROADMAP.md +68 -0
- package/docs/SDK.md +162 -0
- package/docs/external-status.md +6 -6
- package/knowledge/COVERAGE.md +9 -7
- package/knowledge/i18n/korean-density-conventions.md +108 -0
- package/knowledge/i18n/korean-product-conventions.md +2 -0
- package/knowledge/patterns/async-control.md +233 -0
- package/knowledge/patterns/error-states.md +1 -0
- package/knowledge/patterns/form-design.md +2 -0
- package/package.json +6 -2
- package/tools/audit/package-smoke.py +62 -0
- package/tools/audit/registry-smoke.py +5 -5
- package/tools/audit/smoke_assertions.py +3 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<!-- hand-written -->
|
|
2
|
+
---
|
|
3
|
+
title: Korean B2B density conventions
|
|
4
|
+
applies_to: [web, mobile, all-ui, korean]
|
|
5
|
+
version: 1.0.0
|
|
6
|
+
last_updated: 2026-07
|
|
7
|
+
stability: stable
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Korean B2B density conventions
|
|
11
|
+
|
|
12
|
+
Korean enterprise software — ERP, HR, groupware, admin consoles, financial back-office — is visibly denser than its Western counterpart, and denser again than Korean *consumer* apps. A Western enterprise layout ported unchanged into a Korean B2B product reads as sparse, slow, and "not serious." This file is the density half of the Korean playbook; for consumer density and the broader Korean conventions, see [`knowledge/i18n/korean-product-conventions.md`](korean-product-conventions.md).
|
|
13
|
+
|
|
14
|
+
## Why Korean B2B is denser
|
|
15
|
+
|
|
16
|
+
- **Information-per-screen is a value, not a cost.** Power users (accountants, HR admins, operators) work the same screens all day and want everything in view — more rows, more columns, fewer clicks to the next field. Scrolling and pagination read as friction, not breathing room.
|
|
17
|
+
- **The reference points are dense.** 더존, 영림원, SAP-Korea deployments, government e-gov portals, and bank back-office tools set the expectation. Naver Works / Kakao Work admin, Toss Business, and 뱅크샐러드 business consoles are the modern-but-still-dense benchmark.
|
|
18
|
+
- **Hangul carries more meaning per character.** A Korean label is often shorter than its English equivalent for the same content, so the same row holds more without feeling cramped.
|
|
19
|
+
|
|
20
|
+
This is a deliberate register, not clutter. The craft is being dense **and** legible — not dense **and** broken.
|
|
21
|
+
|
|
22
|
+
## The density ladder
|
|
23
|
+
|
|
24
|
+
Pick a mode per surface and hold it. Do not mix comfortable and compact rows in one table.
|
|
25
|
+
|
|
26
|
+
| Mode | Row height (approx) | Use for |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| **Comfortable** | 48–56px | Consumer-facing, onboarding, marketing-in-product, low-frequency admin |
|
|
29
|
+
| **Cozy** (default KR B2B) | 36–44px | Most enterprise tables, forms, lists — the Korean B2B baseline |
|
|
30
|
+
| **Compact** | 28–32px | Data grids for power users, financial ledgers, high-row-count monitoring |
|
|
31
|
+
|
|
32
|
+
Western enterprise defaults to Comfortable; **Korean B2B defaults to Cozy.** Porting in usually means dropping one rung: reduce vertical padding ~15–25% and expect 30–50% more rows per fold. Offer a density toggle (Comfortable / Cozy / Compact) on heavy data surfaces so individual operators can go denser; persist the choice per user.
|
|
33
|
+
|
|
34
|
+
## Tables are the center of gravity
|
|
35
|
+
|
|
36
|
+
Korean B2B UIs are table-first. Most screens are a filter bar plus a table.
|
|
37
|
+
|
|
38
|
+
- **Many columns are normal.** 8–15 columns is routine; horizontal scroll with a **frozen first column** (and often a frozen header row) beats hiding columns behind a menu.
|
|
39
|
+
- **Compact rows, aligned numerals.** Right-align numbers and currency, use tabular figures, and keep row height in the Cozy/Compact band. See [`knowledge/patterns/money-and-amount.md`](../patterns/money-and-amount.md) for amount formatting.
|
|
40
|
+
- **Inline row actions.** Put edit / delete / detail as compact icon buttons or a trailing action cell, not behind a per-row overflow menu that costs an extra click.
|
|
41
|
+
- **Sticky context.** Freeze the header and the key identity column (사번, 거래처, 전표번호) so the operator never loses the row's identity while scrolling wide.
|
|
42
|
+
- **Totals and subtotals in view.** A pinned footer row with sums/counts is expected on financial and inventory tables — operators reconcile against it constantly.
|
|
43
|
+
- **Zebra or hairline rules, not heavy borders.** At Cozy/Compact density, 1px hairline separators or subtle zebra striping keep rows scannable without adding visual weight.
|
|
44
|
+
|
|
45
|
+
## Forms at density
|
|
46
|
+
|
|
47
|
+
- **Label-left (horizontal) layout** is the Korean B2B default for data-entry forms — it packs more fields per vertical space than label-top and matches the ledger-like mental model. Label-top is for consumer/onboarding.
|
|
48
|
+
- **Multi-column forms.** Two or three field columns per row are normal for registration and master-data screens. Group by section with thin dividers, not big gaps.
|
|
49
|
+
- **Tight field spacing.** Reduce inter-field vertical rhythm versus consumer forms; keep it consistent so the grid reads cleanly. See [`knowledge/patterns/form-design.md`](../patterns/form-design.md).
|
|
50
|
+
- **Required/optional density.** Mark required with a compact indicator (asterisk or a small "필수" chip); don't spend a whole helper-text line per field unless the rule is non-obvious.
|
|
51
|
+
- **Keyboard-first.** Power users tab through fields fast. Preserve a logical tab order, support Enter-to-next where appropriate, and don't trap focus in date/select popovers.
|
|
52
|
+
|
|
53
|
+
## Lists, trees, and navigation
|
|
54
|
+
|
|
55
|
+
- **Dense list rows** with secondary metadata inline (status chip, date, owner on one line) rather than stacked. See [`knowledge/patterns/list-and-feed.md`](../patterns/list-and-feed.md).
|
|
56
|
+
- **Tree navigation** (조직도, 계정과목, 메뉴 권한) is common and should stay compact — small row height, clear expand affordances, many levels visible at once.
|
|
57
|
+
- **Persistent side navigation** with dense, possibly two-level menus is expected over hamburger-hidden nav; enterprise users want the whole map visible. This is the opposite of the consumer preference noted in [`korean-product-conventions.md`](korean-product-conventions.md).
|
|
58
|
+
|
|
59
|
+
## Typography at density
|
|
60
|
+
|
|
61
|
+
- **Base size can drop, but not below legibility.** 13–14px body is common in KR B2B tables (vs 16px consumer). Do not go below ~12px for Hangul primary content — Hangul syllable blocks lose legibility faster than Latin at small sizes.
|
|
62
|
+
- **Line-height stays generous relative to size.** Even dense Korean text needs ~1.4–1.5 line-height for the syllable blocks to breathe; do not crush leading to save space. See [`knowledge/i18n/korean-typography.md`](korean-typography.md).
|
|
63
|
+
- **Numerals: tabular, aligned.** Financial density depends on figures lining up.
|
|
64
|
+
- **Truncate with intent.** Ellipsize long values with a tooltip/title for the full string rather than wrapping and breaking row rhythm.
|
|
65
|
+
|
|
66
|
+
## Density must not break accessibility
|
|
67
|
+
|
|
68
|
+
Density is the constraint; accessibility is the floor. When they conflict, accessibility wins.
|
|
69
|
+
|
|
70
|
+
- **Touch targets.** On touch/hybrid B2B (tablets on the floor, POS, hospital carts), keep interactive targets ≥ 44×44px *hit area* even when the visual row is compact — expand the clickable area beyond the visible cell rather than shrinking the target.
|
|
71
|
+
- **Contrast holds at every density.** Hairline separators and secondary text must still meet WCAG contrast; dense does not mean low-contrast gray-on-gray. See [`knowledge/a11y/contrast.md`](../a11y/contrast.md).
|
|
72
|
+
- **Focus visibility.** A visible focus ring is non-negotiable for keyboard-driven data entry; ensure it is not clipped by tight row borders. See [`knowledge/a11y/keyboard-and-focus.md`](../a11y/keyboard-and-focus.md).
|
|
73
|
+
- **Density toggle as an a11y affordance.** Offering Comfortable mode is itself an accessibility accommodation for users who need larger targets and text.
|
|
74
|
+
|
|
75
|
+
## Tokens consumed
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
--space-2xs, --space-xs (dense row padding, inter-field gaps)
|
|
79
|
+
--space-sm (section spacing at density)
|
|
80
|
+
--row-height-compact (or --space-based row sizing)
|
|
81
|
+
--font-size-sm (13–14px dense body)
|
|
82
|
+
--line-height-normal (~1.4–1.5 for Hangul)
|
|
83
|
+
--color-border-subtle (hairline separators)
|
|
84
|
+
--color-bg-subtle (zebra striping)
|
|
85
|
+
--color-text-secondary (inline metadata)
|
|
86
|
+
--color-text-tabular (aligned numerals)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Define a **density scale** as tokens (comfortable / cozy / compact row heights + padding) so the toggle flips a token set, not ad-hoc per-component values.
|
|
90
|
+
|
|
91
|
+
## Don't
|
|
92
|
+
|
|
93
|
+
- Don't ship Western enterprise comfortable-density defaults unchanged into a Korean B2B product — it reads as sparse and unserious.
|
|
94
|
+
- Don't hide columns behind menus to avoid width — Korean power users prefer horizontal scroll with frozen columns.
|
|
95
|
+
- Don't mix row-height modes within one table.
|
|
96
|
+
- Don't crush Hangul below ~12px or crush line-height to gain rows — legibility breaks before the space saved is worth it.
|
|
97
|
+
- Don't let density erode contrast, focus rings, or touch-target hit areas.
|
|
98
|
+
- Don't bury per-row actions behind an overflow menu on high-frequency screens — inline them.
|
|
99
|
+
- Don't apply consumer nav patterns (hamburger, hidden tabs) to enterprise — surface the full dense menu.
|
|
100
|
+
|
|
101
|
+
## Cross-reference
|
|
102
|
+
|
|
103
|
+
- [`knowledge/i18n/korean-product-conventions.md`](korean-product-conventions.md) — consumer density and the broader Korean conventions this file specializes from
|
|
104
|
+
- [`knowledge/i18n/korean-typography.md`](korean-typography.md) — Hangul sizing and line-height at density
|
|
105
|
+
- [`knowledge/patterns/form-design.md`](../patterns/form-design.md) — label-left, multi-column, dense forms
|
|
106
|
+
- [`knowledge/patterns/list-and-feed.md`](../patterns/list-and-feed.md) — dense list rows and data grids
|
|
107
|
+
- [`knowledge/patterns/money-and-amount.md`](../patterns/money-and-amount.md) — tabular numerals and aligned amounts
|
|
108
|
+
- [`knowledge/a11y/contrast.md`](../a11y/contrast.md), [`knowledge/a11y/keyboard-and-focus.md`](../a11y/keyboard-and-focus.md) — the accessibility floor density must respect
|
|
@@ -58,6 +58,8 @@ This is not "cluttered design" — it matches reading pace and screen-time habit
|
|
|
58
58
|
|
|
59
59
|
Reference: see how Coupang, Naver, KakaoTalk, Toss differ from Amazon, Google, WhatsApp, Robinhood.
|
|
60
60
|
|
|
61
|
+
For enterprise / B2B density (denser again — ERP, HR, groupware, admin, financial back-office), see [`knowledge/i18n/korean-density-conventions.md`](korean-density-conventions.md).
|
|
62
|
+
|
|
61
63
|
## Color associations
|
|
62
64
|
|
|
63
65
|
Different from Western color symbolism:
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
<!-- hand-written -->
|
|
2
|
+
---
|
|
3
|
+
title: Async control patterns
|
|
4
|
+
applies_to: [web, mobile, all-ui]
|
|
5
|
+
version: 1.0.0
|
|
6
|
+
last_updated: 2026-07
|
|
7
|
+
stability: stable
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Async control patterns
|
|
11
|
+
|
|
12
|
+
Every action that talks to a server has an in-flight life. This file is about that life — the moment between the tap and the answer — not about the error screen at the end (see [`patterns/error-states.md`](error-states.md) for that). Get the in-flight state wrong and you get the two most common async bugs in product UIs: double submits and stuck spinners.
|
|
13
|
+
|
|
14
|
+
## The async action lifecycle
|
|
15
|
+
|
|
16
|
+
Every async action moves through the same four states. Model them explicitly; do not infer "loading" from the absence of data.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
idle ──trigger──▶ pending ──┬──▶ success ──▶ idle
|
|
20
|
+
▲ │
|
|
21
|
+
└─────────────────────────┴──▶ error ────▶ idle (retryable)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| State | The control shows | The user can |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| **idle** | Its normal, enabled label | Trigger the action |
|
|
27
|
+
| **pending** | Busy affordance + disabled | Wait, or cancel (if cancellable) |
|
|
28
|
+
| **success** | Brief confirmation, then idle | Continue |
|
|
29
|
+
| **error** | Recovery affordance | Retry or recover — see `error-states.md` |
|
|
30
|
+
|
|
31
|
+
Rule: a control never sits in `pending` forever. Every request has a timeout that forces it to `error`.
|
|
32
|
+
|
|
33
|
+
## Action controls during pending
|
|
34
|
+
|
|
35
|
+
The trigger control (button, menu item, toggle) is where most async bugs live.
|
|
36
|
+
|
|
37
|
+
| Do | Don't |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| Disable the control while its own action is in flight | Leave it clickable and hope |
|
|
40
|
+
| Show a busy affordance **inside or beside** the control | Replace the whole screen with a spinner for a single button |
|
|
41
|
+
| Keep the label readable — swap to a present-progressive verb | Blank the label to just a spinner (the user forgets what they clicked) |
|
|
42
|
+
| Restore the control on success **and** error | Leave it disabled after a failed request |
|
|
43
|
+
|
|
44
|
+
Label swap, Korean and English:
|
|
45
|
+
|
|
46
|
+
| Idle | Pending | Done |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| 저장 | 저장 중… | 저장됨 |
|
|
49
|
+
| 불러오기 | 불러오는 중… | — |
|
|
50
|
+
| 결제하기 | 결제 처리 중… | 결제 완료 |
|
|
51
|
+
| 제출 | 제출 중… | — |
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
┌─────────────────┐ ┌─────────────────┐
|
|
55
|
+
│ 저장 │ → │ ◌ 저장 중… │ (disabled, spinner leads label)
|
|
56
|
+
└─────────────────┘ └─────────────────┘
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Double-submit prevention
|
|
60
|
+
|
|
61
|
+
The single most important async rule: **a control that started an action cannot start it again until that action resolves.** Disabling on `pending` handles the fast double-tap. Also guard the handler itself, because disabled is a UI state that a determined tap (or a slow render) can beat.
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
function SubmitButton({ onSubmit }) {
|
|
65
|
+
const [status, setStatus] = useState("idle"); // idle | pending | error
|
|
66
|
+
|
|
67
|
+
async function handleClick() {
|
|
68
|
+
if (status === "pending") return; // guard: not just the disabled attr
|
|
69
|
+
setStatus("pending");
|
|
70
|
+
try {
|
|
71
|
+
await onSubmit();
|
|
72
|
+
setStatus("idle");
|
|
73
|
+
} catch (err) {
|
|
74
|
+
setStatus("error"); // hand off to error-states.md
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Button onClick={handleClick} disabled={status === "pending"} aria-busy={status === "pending"}>
|
|
80
|
+
{status === "pending" ? "저장 중…" : "저장"}
|
|
81
|
+
</Button>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
For money and irreversible actions (payment, transfer, delete), double-submit guarding is a correctness requirement, not a nicety. Pair the client guard with a server-side idempotency key — cite [`patterns/money-and-amount.md`](money-and-amount.md).
|
|
87
|
+
|
|
88
|
+
## Choosing a loading affordance by duration
|
|
89
|
+
|
|
90
|
+
Match the indicator to how long the wait actually is. Guessing wrong is worse than no indicator.
|
|
91
|
+
|
|
92
|
+
| Expected wait | Affordance | Why |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| < 100ms | **Nothing** | Faster than perception; a flashed spinner reads as a glitch |
|
|
95
|
+
| 100ms – 1s | **In-place busy** (button spinner, subtle) | Confirms the tap registered |
|
|
96
|
+
| 1s – 10s | **Skeleton** (for content) or **progress** (for known-length work) | Shows shape/position; feels faster than a spinner |
|
|
97
|
+
| > 10s | **Progress + cancel + background option** | Let the user leave and be notified |
|
|
98
|
+
| Unknown length | **Indeterminate spinner with a timeout** | Never an infinite bar |
|
|
99
|
+
|
|
100
|
+
Spinner vs skeleton: a spinner says "something is happening"; a skeleton says "*this* is what's coming and where." Prefer skeletons for content regions (lists, cards, detail panes) — see [`patterns/list-and-feed.md`](list-and-feed.md). Reserve spinners for actions and small inline waits.
|
|
101
|
+
|
|
102
|
+
Anti-flicker: if you show a delayed spinner (only after 100–300ms), also hold it for a minimum (~300ms) once shown, so a request that resolves at 310ms doesn't flash. Delay-in, min-hold-out.
|
|
103
|
+
|
|
104
|
+
## Optimistic updates
|
|
105
|
+
|
|
106
|
+
Apply the change in the UI immediately, before the server confirms, then reconcile.
|
|
107
|
+
|
|
108
|
+
Use optimistic when: the action almost always succeeds, is cheap to reverse, and the wait would break flow — likes, toggles, reordering, adding a to-do, marking read.
|
|
109
|
+
|
|
110
|
+
Do **not** use optimistic when: the action is money, irreversible, or the server is the source of truth for the result (payment, booking a seat, submitting an exam). There, show real `pending` and wait.
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
tap ─▶ update UI now ─▶ fire request ─┬─ ok: keep, drop the "syncing" mark
|
|
114
|
+
└─ fail: roll back to prior value + show why
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Rules:
|
|
118
|
+
- Keep the pre-action value so you can roll back exactly.
|
|
119
|
+
- Mark optimistic items as unconfirmed (subtle) until the server agrees, so a rollback isn't a jarring surprise.
|
|
120
|
+
- On rollback, tell the user what reverted and why — a silent snap-back looks like a bug.
|
|
121
|
+
|
|
122
|
+
## Debounce and throttle for async-triggering input
|
|
123
|
+
|
|
124
|
+
Input that fires requests as the user types or drags must be rate-limited, or it floods the server and races itself.
|
|
125
|
+
|
|
126
|
+
| Technique | Fires | Use for |
|
|
127
|
+
| --- | --- | --- |
|
|
128
|
+
| **Debounce** (wait for a pause) | Once, after input stops for N ms | Search-as-you-type (250–350ms), autosave (500ms–2s) |
|
|
129
|
+
| **Throttle** (at most every N ms) | On a steady cadence | Drag/scroll-driven fetches, live position updates |
|
|
130
|
+
| **Leading + trailing** | Immediately, then once more at the end | Feels responsive but still settles |
|
|
131
|
+
|
|
132
|
+
Debounce is about *when to fire*; cancellation (next section) is about *what to do with requests already in flight*. You need both.
|
|
133
|
+
|
|
134
|
+
## Cancellation and out-of-order responses
|
|
135
|
+
|
|
136
|
+
Two requests fired in order can return out of order. Without cancellation, an older, slower response overwrites a newer one — the classic "I typed 서울, it shows 서" bug.
|
|
137
|
+
|
|
138
|
+
- Cancel the previous in-flight request when a new one supersedes it (`AbortController` on the web).
|
|
139
|
+
- Cancel in-flight requests when the component unmounts or the user navigates away.
|
|
140
|
+
- If you cannot cancel, **guard on apply**: tag each request and ignore any response that isn't the latest.
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
fetchResults(query, { signal: controller.signal })
|
|
146
|
+
.then(setResults)
|
|
147
|
+
.catch(ignoreAbort);
|
|
148
|
+
return () => controller.abort(); // supersede on new query / unmount
|
|
149
|
+
}, [query]);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Concurrency policy
|
|
153
|
+
|
|
154
|
+
When the same action can be triggered while one is running, decide the policy on purpose:
|
|
155
|
+
|
|
156
|
+
| Policy | Behavior | Use for |
|
|
157
|
+
| --- | --- | --- |
|
|
158
|
+
| **Block** | Ignore new triggers until the current resolves | Submits, payments (with double-submit guard) |
|
|
159
|
+
| **Latest-wins** | Cancel the running one, keep only the newest | Search, filters, autosave |
|
|
160
|
+
| **Queue** | Run in order, one at a time | Ordered writes that must all land |
|
|
161
|
+
| **Parallel** | Let all run | Independent reads |
|
|
162
|
+
|
|
163
|
+
Default to **block** for writes and **latest-wins** for reads unless you have a reason otherwise.
|
|
164
|
+
|
|
165
|
+
## Timeouts and the stuck-spinner failure
|
|
166
|
+
|
|
167
|
+
A spinner with no timeout is a trap: on a dropped connection it spins forever and the user is stuck.
|
|
168
|
+
|
|
169
|
+
- Give every request a client timeout (e.g. 10–30s by operation).
|
|
170
|
+
- On timeout, transition to `error` with a retry — hand off to [`patterns/error-states.md`](error-states.md) (network/server section, retry with backoff).
|
|
171
|
+
- For genuinely long work (video processing, exports), don't hold a spinner — move to a **background job** pattern: acknowledge, let the user leave, notify on completion.
|
|
172
|
+
|
|
173
|
+
## Perceived performance
|
|
174
|
+
|
|
175
|
+
The felt speed of an async UI is mostly about what you show during the wait:
|
|
176
|
+
|
|
177
|
+
- **Optimistic UI** — instant for reversible actions (above).
|
|
178
|
+
- **Skeletons** — for content; show structure, not a void.
|
|
179
|
+
- **Stale-while-revalidate** — show the last known data immediately, refresh in the background, swap in quietly. Mark it subtly as refreshing so a change isn't startling.
|
|
180
|
+
- **Progressive reveal** — render what's ready (shell, then data) instead of blocking on the slowest piece.
|
|
181
|
+
|
|
182
|
+
## Tokens consumed
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
--color-accent (spinner, progress fill)
|
|
186
|
+
--color-text-secondary (pending label, "저장 중…")
|
|
187
|
+
--color-text-tertiary (unconfirmed / optimistic mark, "동기화 중")
|
|
188
|
+
--color-bg-subtle (skeleton base)
|
|
189
|
+
--color-bg-elevated (skeleton shimmer highlight)
|
|
190
|
+
--radius-md
|
|
191
|
+
--space-xs, --space-sm
|
|
192
|
+
--duration-fast (spinner delay-in / min-hold)
|
|
193
|
+
--easing-standard
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Accessibility
|
|
197
|
+
|
|
198
|
+
- Set `aria-busy="true"` on a control or region while its action is pending; clear it when resolved.
|
|
199
|
+
- Announce state changes politely: a visually-hidden `role="status"` (`aria-live="polite"`) region saying "저장 중" then "저장됨". Do not use `assertive` for routine progress — reserve that for errors (see `error-states.md`).
|
|
200
|
+
- Keep the control's accessible name stable and meaningful during pending — the label swap ("저장" → "저장 중…") is fine; an icon-only spinner with no name is not.
|
|
201
|
+
- On success that removes the control (e.g. a submit that navigates), move focus deliberately to the next logical element; never drop focus to `<body>`.
|
|
202
|
+
- Respect `prefers-reduced-motion`: swap spinners/shimmer for a static or fade indicator.
|
|
203
|
+
- Disabled-while-pending must still be perceivable — don't rely on color alone; the busy affordance carries the meaning.
|
|
204
|
+
|
|
205
|
+
## Korean considerations
|
|
206
|
+
|
|
207
|
+
Per [`knowledge/i18n/korean-product-conventions.md`](../i18n/korean-product-conventions.md):
|
|
208
|
+
- Progress labels use the present-progressive `~중…`: "저장 중…", "불러오는 중…", "처리 중…", "결제 처리 중…".
|
|
209
|
+
- Keep pending labels short — Korean verbs + `중…` stay compact, so the button width rarely jumps.
|
|
210
|
+
- For payment and transfer flows, show explicit real `pending` (never optimistic) and a clear "결제 처리 중…" state; cite [`knowledge/i18n/korean-payments.md`](../i18n/korean-payments.md) for the surrounding flow and failure messages.
|
|
211
|
+
- Dense Korean B2B layouts favor in-place busy affordances (inline spinner, row-level skeleton) over full-page blocking overlays — cite [`knowledge/i18n/korean-product-conventions.md`](../i18n/korean-product-conventions.md) on density.
|
|
212
|
+
|
|
213
|
+
## Don't
|
|
214
|
+
|
|
215
|
+
- Don't leave a control clickable during its own async action — the double-submit bug.
|
|
216
|
+
- Don't guard double-submit with the disabled attribute alone; guard the handler too.
|
|
217
|
+
- Don't show a spinner for a sub-100ms wait — it reads as a flicker.
|
|
218
|
+
- Don't show a spinner that can spin forever — every request has a timeout.
|
|
219
|
+
- Don't blank a button to a bare spinner; keep a readable pending label.
|
|
220
|
+
- Don't use optimistic updates for money, payments, or irreversible actions.
|
|
221
|
+
- Don't let an older response overwrite a newer one — cancel or guard-on-apply.
|
|
222
|
+
- Don't hold a full-screen spinner for long work — move it to a background job with notify.
|
|
223
|
+
- Don't roll back an optimistic change silently — say what reverted and why.
|
|
224
|
+
- Don't announce routine progress with `aria-live="assertive"` — that's for errors.
|
|
225
|
+
|
|
226
|
+
## Cross-reference
|
|
227
|
+
|
|
228
|
+
- [`knowledge/patterns/error-states.md`](error-states.md) — where the `error` state goes: recovery, retry-with-backoff, network/server messages
|
|
229
|
+
- [`knowledge/patterns/form-design.md`](form-design.md) — submit buttons, validation-in-flight, form-level pending
|
|
230
|
+
- [`knowledge/patterns/list-and-feed.md`](list-and-feed.md) — skeletons, pull-to-refresh, infinite scroll loading
|
|
231
|
+
- [`knowledge/patterns/empty-states.md`](empty-states.md) — the resolved-but-no-data end state, distinct from loading
|
|
232
|
+
- [`knowledge/patterns/money-and-amount.md`](money-and-amount.md) — why money actions use real pending + idempotency, never optimistic
|
|
233
|
+
- [`knowledge/i18n/korean-payments.md`](../i18n/korean-payments.md) — payment in-flight and failure states
|
|
@@ -312,6 +312,7 @@ function PageContent() {
|
|
|
312
312
|
|
|
313
313
|
## Cross-reference
|
|
314
314
|
|
|
315
|
+
- [`knowledge/patterns/async-control.md`](async-control.md) — the in-flight state before the error: pending, retry-on-timeout, double-submit
|
|
315
316
|
- [`knowledge/patterns/empty-states.md`](empty-states.md) — when there's no data, but it's not an error
|
|
316
317
|
- [`knowledge/patterns/form-design.md`](form-design.md) — validation errors at the field level
|
|
317
318
|
- [`knowledge/i18n/korean-payments.md`](../i18n/korean-payments.md) — payment-specific error messages
|
|
@@ -232,6 +232,8 @@ Required affordances:
|
|
|
232
232
|
|
|
233
233
|
## Cross-reference
|
|
234
234
|
|
|
235
|
+
- [`knowledge/patterns/async-control.md`](async-control.md) — submit-in-flight, double-submit prevention, pending button states
|
|
236
|
+
|
|
235
237
|
- [knowledge/patterns/ux-guidelines.md](ux-guidelines.md) — broader UX rules
|
|
236
238
|
- [knowledge/i18n/korean-product-conventions.md](../i18n/korean-product-conventions.md) — Korean form expectations (phone, address, terms)
|
|
237
239
|
- [knowledge/a11y/keyboard-and-focus.md](../a11y/keyboard-and-focus.md) — keyboard navigation contract
|
package/package.json
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@design-ai/cli",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.59.0",
|
|
4
4
|
"description": "Senior product designer for any AI coding agent. Installs design-ai (20 skills, 17 commands, 4 agents) into Claude Code globally. Korean market depth plus website improvement control tower.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"design-ai": "cli/bin/design-ai.mjs",
|
|
7
7
|
"design-ai-mcp": "cli/bin/design-ai-mcp.mjs"
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
"./sdk": "./cli/sdk/index.mjs",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
10
14
|
"engines": {
|
|
11
15
|
"node": ">=18"
|
|
12
16
|
},
|
|
13
17
|
"scripts": {
|
|
14
|
-
"test": "node --test cli/lib/*.test.mjs",
|
|
18
|
+
"test": "node --test cli/lib/*.test.mjs cli/sdk/*.test.mjs",
|
|
15
19
|
"audit": "python3 -B tools/audit/run-all.py",
|
|
16
20
|
"audit:strict": "python3 -B tools/audit/run-all.py --strict",
|
|
17
21
|
"audit:runner:self-test": "python3 -B tools/audit/run-all.py --self-test",
|
|
@@ -763,6 +763,63 @@ def assert_version_json_smoke(cmd: list[str], *, env: dict[str, str], cwd: Path
|
|
|
763
763
|
assert_version_json(result.stdout, context=context, cmd=cmd)
|
|
764
764
|
|
|
765
765
|
|
|
766
|
+
# Phase A Agent SDK packed-tarball smoke (docs/AGENT-SDK.md, docs/SDK.md): imports
|
|
767
|
+
# `@design-ai/cli/sdk` from the installed package (proving the package.json
|
|
768
|
+
# "exports" map resolves) and exercises route/search(ranked)/recall, asserting
|
|
769
|
+
# each call is deterministic (same input -> same JSON-serialized output) and
|
|
770
|
+
# that Phase A's read-only contract holds (prompt/pack never include
|
|
771
|
+
# learningUsage). This is a Node script, not a design-ai CLI invocation, so it
|
|
772
|
+
# is run directly rather than through npm_exec_cmd/design-ai bin helpers.
|
|
773
|
+
SDK_SMOKE_SCRIPT = """
|
|
774
|
+
import { check, pack, prompt, recall, route, routes, search, version } from "@design-ai/cli/sdk";
|
|
775
|
+
|
|
776
|
+
const brief = "Spec a Button component API with variants, props, and keyboard accessibility";
|
|
777
|
+
|
|
778
|
+
const r1 = route(brief, { limit: 1 });
|
|
779
|
+
const r2 = route(brief, { limit: 1 });
|
|
780
|
+
if (JSON.stringify(r1) !== JSON.stringify(r2)) throw new Error("route() not deterministic");
|
|
781
|
+
if (r1[0].id !== "component-spec") throw new Error(`unexpected route id: ${r1[0].id}`);
|
|
782
|
+
|
|
783
|
+
const s1 = search("accessibility", { ranked: true, limit: 3 });
|
|
784
|
+
const s2 = search("accessibility", { ranked: true, limit: 3 });
|
|
785
|
+
if (JSON.stringify(s1) !== JSON.stringify(s2)) throw new Error("search() not deterministic");
|
|
786
|
+
if (s1.length === 0) throw new Error("search() returned no hits");
|
|
787
|
+
|
|
788
|
+
const rec1 = recall("keyboard accessibility button", { limit: 2 });
|
|
789
|
+
const rec2 = recall("keyboard accessibility button", { limit: 2 });
|
|
790
|
+
if (JSON.stringify(rec1) !== JSON.stringify(rec2)) throw new Error("recall() not deterministic");
|
|
791
|
+
if (!rec1.corpus || !rec1.learning) throw new Error("recall() missing corpus/learning");
|
|
792
|
+
|
|
793
|
+
const v = version();
|
|
794
|
+
if (typeof v.cli !== "string" || typeof v.corpus !== "string") throw new Error("version() bad shape");
|
|
795
|
+
|
|
796
|
+
const rc = routes();
|
|
797
|
+
if (!Array.isArray(rc.routes) || rc.routes.length === 0) throw new Error("routes() bad shape");
|
|
798
|
+
|
|
799
|
+
const p = prompt(brief, { routeId: "component-spec" });
|
|
800
|
+
if (Object.hasOwn(p, "learningUsage")) throw new Error("prompt() must not include learningUsage in Phase A");
|
|
801
|
+
|
|
802
|
+
const pk = pack(brief, { routeId: "component-spec", maxBytes: 20000 });
|
|
803
|
+
if (Object.hasOwn(pk, "learningUsage")) throw new Error("pack() must not include learningUsage in Phase A");
|
|
804
|
+
|
|
805
|
+
const ck = check(
|
|
806
|
+
"# T\\n\\nAnatomy variants API contrast 4.5:1 keyboard focus screen reader aria- responsive mobile desktop.",
|
|
807
|
+
{ routeId: "component-spec" },
|
|
808
|
+
);
|
|
809
|
+
if (typeof ck.status !== "string") throw new Error("check() bad shape");
|
|
810
|
+
|
|
811
|
+
console.log("design-ai sdk smoke passed");
|
|
812
|
+
""".strip()
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def assert_sdk_smoke(*, cwd: Path, env: dict[str, str], context: str) -> None:
|
|
816
|
+
result = run_plain(["node", "--input-type=module", "-e", SDK_SMOKE_SCRIPT], cwd=cwd, env=env)
|
|
817
|
+
if result.returncode != 0:
|
|
818
|
+
raise SystemExit(f"{context} failed (exit {result.returncode})")
|
|
819
|
+
if "design-ai sdk smoke passed" not in result.stdout:
|
|
820
|
+
raise SystemExit(f"{context}: expected success marker missing from output")
|
|
821
|
+
|
|
822
|
+
|
|
766
823
|
def assert_workspace_json_smoke(cmd: list[str], *, env: dict[str, str], cwd: Path | None = None, context: str) -> None:
|
|
767
824
|
result = run_plain(cmd, cwd=cwd, env=env)
|
|
768
825
|
assert_workspace_json(result.stdout, context=context, cmd=cmd)
|
|
@@ -20239,6 +20296,11 @@ def smoke_tarball(tarball: Path) -> None:
|
|
|
20239
20296
|
env=smoke_env,
|
|
20240
20297
|
context="package smoke installed bin version JSON",
|
|
20241
20298
|
)
|
|
20299
|
+
assert_sdk_smoke(
|
|
20300
|
+
cwd=install_root,
|
|
20301
|
+
env=smoke_env,
|
|
20302
|
+
context="package smoke installed Agent SDK (@design-ai/cli/sdk)",
|
|
20303
|
+
)
|
|
20242
20304
|
assert_design_ai_mcp_protocol_smoke(
|
|
20243
20305
|
[str(mcp_bin_path)],
|
|
20244
20306
|
cwd=install_root,
|
|
@@ -4637,7 +4637,7 @@ def assert_learning_relevance_context(payload: dict[str, object], *, context: st
|
|
|
4637
4637
|
message="learning selection explanation should mark the relevant entry as a brief match",
|
|
4638
4638
|
)
|
|
4639
4639
|
require_registry_smoke(
|
|
4640
|
-
|
|
4640
|
+
type(selected_entry.get("score")) in (int, float) and selected_entry.get("score") > 0,
|
|
4641
4641
|
context=context,
|
|
4642
4642
|
cmd=cmd,
|
|
4643
4643
|
message="learning selection explanation should include a positive relevance score",
|
|
@@ -4759,7 +4759,7 @@ def assert_learning_query_json(
|
|
|
4759
4759
|
require_registry_smoke(
|
|
4760
4760
|
selected[0].get("id") == "learn-relevant"
|
|
4761
4761
|
and selected[0].get("reason") == "brief-match"
|
|
4762
|
-
and
|
|
4762
|
+
and type(selected[0].get("score")) in (int, float)
|
|
4763
4763
|
and selected[0].get("score") > 0,
|
|
4764
4764
|
context=context,
|
|
4765
4765
|
cmd=cmd,
|
|
@@ -4785,7 +4785,7 @@ def assert_learning_query_human(raw: str, *, context: str, cmd: list[str]) -> No
|
|
|
4785
4785
|
"Limit: 2",
|
|
4786
4786
|
"Explain: selection score, matched tokens, and reason",
|
|
4787
4787
|
"[accessibility] Prioritize keyboard accessibility details for Button component API specs",
|
|
4788
|
-
"matched
|
|
4788
|
+
"matched accessibility, keyboard",
|
|
4789
4789
|
"reason brief-match",
|
|
4790
4790
|
):
|
|
4791
4791
|
require_registry_smoke(
|
|
@@ -8378,7 +8378,7 @@ def run_self_test() -> None:
|
|
|
8378
8378
|
"",
|
|
8379
8379
|
"1. [accessibility] Prioritize keyboard accessibility details for Button component API specs",
|
|
8380
8380
|
" learn-relevant · 2026-05-22T00:00:01.000Z",
|
|
8381
|
-
" score
|
|
8381
|
+
" score 2.114533 · matched accessibility, keyboard · reason brief-match",
|
|
8382
8382
|
]),
|
|
8383
8383
|
context="registry smoke self-test",
|
|
8384
8384
|
cmd=learn_query_human_cmd,
|
|
@@ -8392,7 +8392,7 @@ def run_self_test() -> None:
|
|
|
8392
8392
|
"Limit: 2",
|
|
8393
8393
|
"Explain: selection score, matched tokens, and reason",
|
|
8394
8394
|
"[accessibility] Prioritize keyboard accessibility details for Button component API specs",
|
|
8395
|
-
"matched
|
|
8395
|
+
"matched accessibility, keyboard",
|
|
8396
8396
|
]),
|
|
8397
8397
|
context="registry smoke self-test",
|
|
8398
8398
|
cmd=learn_query_human_cmd,
|
|
@@ -2246,6 +2246,7 @@ def passing_route_explain_human_output() -> str:
|
|
|
2246
2246
|
f" agent: ✓ {EXPECTED_ROUTE_AGENT}",
|
|
2247
2247
|
" read: ✓ knowledge/PRINCIPLES.md",
|
|
2248
2248
|
f" read: ✓ {EXPECTED_ROUTE_KNOWLEDGE}",
|
|
2249
|
+
" related: knowledge/patterns/design-system-qa.md (score 10.13)",
|
|
2249
2250
|
"",
|
|
2250
2251
|
])
|
|
2251
2252
|
|
|
@@ -3714,6 +3715,8 @@ def assert_route_explain_human_output(raw: str, *, context: str, cmd: list[str])
|
|
|
3714
3715
|
f"agent: ✓ {EXPECTED_ROUTE_AGENT}",
|
|
3715
3716
|
"read: ✓ knowledge/PRINCIPLES.md",
|
|
3716
3717
|
f"read: ✓ {EXPECTED_ROUTE_KNOWLEDGE}",
|
|
3718
|
+
# Advisory related-knowledge recall is surfaced only under --explain.
|
|
3719
|
+
"related: knowledge/",
|
|
3717
3720
|
),
|
|
3718
3721
|
context=context,
|
|
3719
3722
|
label="route explain human output",
|