makersclaw 0.3.2 → 0.5.1
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 +3 -1
- package/package.json +1 -1
- package/r/annotate.json +3 -3
- package/r/approval-request.json +4 -4
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/board.json +2 -2
- package/r/calendar-view.json +9 -9
- package/r/cart.json +2 -2
- package/r/chart.json +7 -7
- package/r/chat.json +3 -3
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +5 -5
- package/r/contact-section.json +2 -2
- package/r/core.json +17 -11
- package/r/csv-import.json +3 -3
- package/r/data-table.json +4 -4
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/flow-canvas.json +4 -4
- package/r/footer.json +2 -2
- package/r/gantt.json +3 -3
- package/r/job-panel.json +8 -5
- package/r/json-editor.json +3 -3
- package/r/map.json +2 -2
- package/r/menubar.json +2 -2
- package/r/mind-map.json +6 -6
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- package/r/page-builder.json +2 -2
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +3 -3
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-notes.json +6 -6
- package/r/registry.json +131 -123
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +4 -4
- package/r/signature-pad.json +3 -3
- package/r/sketch-canvas.json +2 -2
- package/r/spreadsheet.json +3 -3
- package/r/stat-cards.json +3 -3
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/transaction-list.json +2 -2
- package/r/whiteboard.json +3 -3
- package/registry/blocks/approval-request/README.md +2 -1
- package/registry/blocks/approval-request/approval-request.jsx +5 -3
- package/registry/blocks/calendar-view/README.md +8 -0
- package/registry/blocks/calendar-view/agenda.jsx +5 -4
- package/registry/blocks/calendar-view/calendar.jsx +23 -8
- package/registry/blocks/calendar-view/demo.jsx +2 -0
- package/registry/blocks/calendar-view/month.jsx +6 -5
- package/registry/blocks/calendar-view/time-grid.jsx +9 -7
- package/registry/blocks/calendar-view/tray.jsx +5 -4
- package/registry/blocks/chart/README.md +9 -4
- package/registry/blocks/chart/cartesian.jsx +31 -24
- package/registry/blocks/chart/core.jsx +21 -0
- package/registry/blocks/chart/pie.jsx +15 -10
- package/registry/blocks/chart/sparkline.jsx +32 -14
- package/registry/blocks/chat/message.jsx +4 -4
- package/registry/blocks/comment-box/README.md +1 -1
- package/registry/blocks/comment-box/comment-time.jsx +4 -6
- package/registry/blocks/data-table/README.md +4 -0
- package/registry/blocks/data-table/data-table.jsx +20 -6
- package/registry/blocks/flow-canvas/edges.jsx +4 -2
- package/registry/blocks/flow-canvas/theme.js +59 -48
- package/registry/blocks/job-panel/README.md +40 -3
- package/registry/blocks/job-panel/block.json +2 -2
- package/registry/blocks/job-panel/demo.jsx +75 -6
- package/registry/blocks/job-panel/job-panel.jsx +60 -11
- package/registry/blocks/mind-map/README.md +9 -2
- package/registry/blocks/mind-map/mind-map.jsx +51 -12
- package/registry/blocks/mind-map/tree.js +33 -0
- package/registry/blocks/org-chart/org-chart.jsx +1 -1
- package/registry/blocks/pdf-viewer/notes.jsx +4 -3
- package/registry/blocks/record-notes/README.md +1 -1
- package/registry/blocks/record-notes/record-notes.jsx +4 -5
- package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
- package/registry/blocks/sidebar/sidebar.jsx +13 -13
- package/registry/blocks/signature-pad/signature.js +8 -1
- package/registry/blocks/spreadsheet/cells.jsx +2 -2
- package/src/shadcn.mjs +1 -1
- package/templates/next/files/README.md +410 -86
- package/templates/next/files/app/auto-refresh.jsx +42 -15
- package/templates/next/files/app/claw-alive/route.js +17 -0
- package/templates/next/files/app/error-relay.jsx +9 -4
- package/templates/next/files/app/escape-relay.jsx +27 -0
- package/templates/next/files/app/frame.js +409 -0
- package/templates/next/files/app/layout.jsx +18 -3
- package/templates/next/files/check/run.mjs +51 -18
- package/templates/next/files/components/parts/fixture.jsx +29 -4
- package/templates/next/files/components/parts/index.js +2 -1
- package/templates/next/files/components/parts/live-work.jsx +235 -0
- package/templates/next/files/components/ui/button.jsx +6 -1
- package/templates/next/files/components/ui/calendar.jsx +24 -0
- package/templates/next/files/components/ui/date-picker.jsx +15 -2
- package/templates/next/files/components/ui/field.jsx +12 -3
- package/templates/next/files/components/ui/icon.jsx +37 -2
- package/templates/next/files/components/ui/index.js +1 -0
- package/templates/next/files/components/ui/sheet.jsx +38 -2
- package/templates/next/files/components/ui/toggle.jsx +20 -2
- package/templates/next/files/lib/actions.js +63 -5
- package/templates/next/files/lib/claw.js +127 -28
- package/templates/next/files/lib/data.js +4 -1
- package/templates/next/files/lib/db.js +18 -1
- package/templates/next/files/lib/hooks.js +493 -37
- package/templates/next/files/lib/platform.js +113 -15
- package/templates/next/files/surfaces/README.md +3 -0
- package/templates/next/template.json +19 -8
|
@@ -48,7 +48,7 @@ const STATIC_ONLY = process.argv.includes("--static");
|
|
|
48
48
|
// update` lays both folders again.
|
|
49
49
|
// Exact names AND extensions: Next resolves .js before .jsx, so a
|
|
50
50
|
// components/ui/depiction.js beside depiction.jsx would win unseen.
|
|
51
|
-
const KIT = /^components\/(?:ui\/(?:cx|index)\.js|ui\/(?:radio|toggle|button-group|avatar|breadcrumb|pagination|button|collapsible|item|table|depiction|feedback|stat|distribution|swatch|steps|separator|scroll-area|field|icon|menu|context-menu|popover|calendar|date-picker|tooltip|notice|sheet|tabs|slider|input-group|image|file-input|input-otp|command|combobox|rating|toast|carousel|resizable)\.jsx|parts\/(?:index|sample)\.js|parts\/(?:fixture|record-switcher)\.jsx)$/;
|
|
51
|
+
const KIT = /^components\/(?:ui\/(?:cx|index)\.js|ui\/(?:radio|toggle|button-group|avatar|breadcrumb|pagination|button|collapsible|item|table|depiction|feedback|stat|distribution|swatch|steps|separator|scroll-area|field|icon|menu|context-menu|popover|calendar|date-picker|tooltip|notice|sheet|tabs|slider|input-group|image|file-input|input-otp|command|combobox|rating|toast|carousel|resizable)\.jsx|parts\/(?:index|sample)\.js|parts\/(?:fixture|record-switcher|live-work)\.jsx)$/;
|
|
52
52
|
|
|
53
53
|
// The pins: sha256 of every fixed file, written by `makersclaw init` and
|
|
54
54
|
// `makersclaw add` (check/pins.json). Read first, because a BLOCK is
|
|
@@ -73,17 +73,35 @@ const DEPICTION = /^components\/depictions\//;
|
|
|
73
73
|
* preview can follow typing). Never the shell, the kit or lib/. */
|
|
74
74
|
const FRAME_HOST = /^(?:surfaces\/[^/]+\.jsx|components\/[^/]+\.jsx)$/;
|
|
75
75
|
|
|
76
|
+
// Folders a build, an install or a tool leaves in the app, never its
|
|
77
|
+
// source: ONE list for every walk here — the rules, the smoke build's
|
|
78
|
+
// freshness, its copy in the sandbox. Until 0.5.0 each walk named its own
|
|
79
|
+
// two or three, and the rules read a temporary .next-stub a build had
|
|
80
|
+
// left (phase-planner, 2026-09-23; CLAW-FIXES #36). A name ending in *
|
|
81
|
+
// is a prefix (.next, .next-check, .next-stub…). node_modules and .git
|
|
82
|
+
// are skipped wherever they are; the rest at the app's root only, where
|
|
83
|
+
// the build puts them: a folder of the app's own may be called out/.
|
|
84
|
+
const BUILD_DIRS = ["node_modules", ".git", ".next*", "out", ".turbo", ".cache"];
|
|
85
|
+
const ANY_DEPTH = new Set(["node_modules", ".git"]);
|
|
86
|
+
const isBuildDir = (name) => BUILD_DIRS.some((d) => (d.endsWith("*") ? name.startsWith(d.slice(0, -1)) : name === d));
|
|
87
|
+
/** Is this path, relative to the app, inside a folder that is not source? */
|
|
88
|
+
function notSource(rel) {
|
|
89
|
+
const parts = rel.split("/");
|
|
90
|
+
return isBuildDir(parts[0]) || parts.some((part) => ANY_DEPTH.has(part));
|
|
91
|
+
}
|
|
92
|
+
|
|
76
93
|
// Files the rules skip: the kit (it owns the native elements and the
|
|
77
|
-
// fallback colours), the fixed template files,
|
|
78
|
-
//
|
|
94
|
+
// fallback colours), the fixed template files, the check itself. Build
|
|
95
|
+
// folders never reach this list (walk, below). Depiction files are NOT
|
|
96
|
+
// skipped — the `depictions` rule reads them.
|
|
79
97
|
const SKIP = [
|
|
80
|
-
/^node_modules\//,
|
|
81
|
-
/^\.next(-check)?\//,
|
|
82
98
|
/^check\//,
|
|
83
99
|
KIT,
|
|
84
100
|
/^app\/globals\.css$/,
|
|
85
101
|
/^lib\/(db|data|actions|claw|platform|hooks|sample)\.js$/,
|
|
86
|
-
/^app\/(layout|page|error|global-error|not-found|error-relay|auto-refresh)\.jsx$/,
|
|
102
|
+
/^app\/(layout|page|error|global-error|not-found|error-relay|auto-refresh|escape-relay)\.jsx$/,
|
|
103
|
+
/^app\/frame\.js$/,
|
|
104
|
+
/^app\/claw-alive\/route\.js$/,
|
|
87
105
|
/^app\/s\/\[key\]\/page\.jsx$/,
|
|
88
106
|
/^middleware\.js$/,
|
|
89
107
|
/^next\.config\.mjs$/,
|
|
@@ -92,12 +110,12 @@ const SKIP = [
|
|
|
92
110
|
/\.(md|sql|json|lock)$/,
|
|
93
111
|
];
|
|
94
112
|
|
|
95
|
-
// Only
|
|
113
|
+
// Only build folders are skipped (BUILD_DIRS): a dot-named module
|
|
96
114
|
// (components/.pictures.js) is a module Next bundles all the same.
|
|
97
115
|
function walk(dir, out = []) {
|
|
98
116
|
for (const name of readdirSync(dir)) {
|
|
99
|
-
if (name === "node_modules" || name === ".git" || name === ".next" || name === ".next-check") continue;
|
|
100
117
|
const full = join(dir, name);
|
|
118
|
+
if (notSource(relative(ROOT, full))) continue;
|
|
101
119
|
if (statSync(full).isDirectory()) walk(full, out);
|
|
102
120
|
else out.push(full);
|
|
103
121
|
}
|
|
@@ -223,7 +241,10 @@ const ATTRS = String.raw`(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}|[^>"'{])*`
|
|
|
223
241
|
if (!L.count) ok("controls", "every control is a kit part");
|
|
224
242
|
}
|
|
225
243
|
|
|
226
|
-
// 3. The example is a fixture in code — never a row in the shared database
|
|
244
|
+
// 3. The example is a fixture in code — never a row in the shared database
|
|
245
|
+
// — and since 0.4.0 it shows only outside a workspace: useFixture reads
|
|
246
|
+
// the layout's <Examples allowed>, off in every workspace app, where an
|
|
247
|
+
// empty table shows its live work (LiveEmpty) instead.
|
|
227
248
|
{
|
|
228
249
|
const L = limiter("examples");
|
|
229
250
|
scan(/\bINSERT\s+INTO\b/i, (f, m, line) => L.hit(`${f.path}:${line}`, "insert", "SQL INSERT outside lib/actions.js; insertRow() is the write door, and a fixture is never inserted"), files);
|
|
@@ -234,11 +255,14 @@ const ATTRS = String.raw`(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}|[^>"'{])*`
|
|
|
234
255
|
// them is the one that must run them through useFixture(). A depiction
|
|
235
256
|
// never holds one: its rows arrive as props from the surface.
|
|
236
257
|
const renders = /<\/|\/>/.test(f.code);
|
|
237
|
-
|
|
258
|
+
// Only useFixture reads the layout's <Examples allowed>: markSample
|
|
259
|
+
// gives rows their ids and nothing else, so a surface that marks its
|
|
260
|
+
// own fixture would show it inside a workspace too.
|
|
261
|
+
const uses = /\buseFixture\(/.test(f.code);
|
|
238
262
|
const server = /from\s*["']\.\.?\/lib\/data["']/.test(f.code);
|
|
239
|
-
if (renders && !uses) L.hit(`${f.path}:${lineOf(f.code, decl.index)}`, "fixture", `${decl[1]} is rendered without useFixture() —
|
|
263
|
+
if (renders && !uses) L.hit(`${f.path}:${lineOf(f.code, decl.index)}`, "fixture", `${decl[1]} is rendered without useFixture() — examples show only outside a workspace (useFixture); a workspace app opens empty${DEPICTION.test(f.path) ? " (a depiction takes its rows as props; the surface holds the fixture)" : server ? " (a server surface cannot call it: pass the rows down and let the client component run useFixture)" : ""}`);
|
|
240
264
|
}
|
|
241
|
-
if (!L.count) ok("examples", "
|
|
265
|
+
if (!L.count) ok("examples", "examples show only outside a workspace (useFixture); a workspace app opens empty");
|
|
242
266
|
}
|
|
243
267
|
|
|
244
268
|
// 4. The manifest names surfaces that exist, once each, under keys that are free.
|
|
@@ -270,7 +294,10 @@ const surfaceKeys = [];
|
|
|
270
294
|
}
|
|
271
295
|
|
|
272
296
|
// 5. Every icon named exists in the sprite (a depiction's own glyphs are
|
|
273
|
-
// not sprite names).
|
|
297
|
+
// not sprite names). The sprite is a kit file, fixed like the rest (the
|
|
298
|
+
// next rule refuses an edit to it), so the way out is another name —
|
|
299
|
+
// until 0.3.3 this line said "Add the glyph there", a door the next
|
|
300
|
+
// rule shuts.
|
|
274
301
|
{
|
|
275
302
|
const sprite = readFileSync(join(ROOT, "components/ui/icon.jsx"), "utf8");
|
|
276
303
|
const names = new Set([...sprite.matchAll(/^\s+"?([a-z][a-z0-9-]*)"?:\s*"/gm)].map((m) => m[1]));
|
|
@@ -282,7 +309,7 @@ const surfaceKeys = [];
|
|
|
282
309
|
scan(/\bicon(?:=|:\s*)["']([a-z][a-z0-9-]*)["']/, (f, m, line) => check(f, m[1], line));
|
|
283
310
|
if (missing.size) {
|
|
284
311
|
const where = [...missing].map(([n, at]) => `"${n}" (${at})`).join(", ");
|
|
285
|
-
fail("icons", "sprite", `not in components/ui/icon.jsx: ${where}.
|
|
312
|
+
fail("icons", "sprite", `not in components/ui/icon.jsx: ${where}. The kit is fixed, so pick one of: ${[...names].join(" ")}`);
|
|
286
313
|
} else ok("icons", "every icon name exists");
|
|
287
314
|
}
|
|
288
315
|
|
|
@@ -531,12 +558,12 @@ const surfaceKeys = [];
|
|
|
531
558
|
|
|
532
559
|
// 6. Smoke: the app, built and started, answers every surface.
|
|
533
560
|
|
|
534
|
-
/** Newest mtime among the app's own sources (what a build reads)
|
|
561
|
+
/** Newest mtime among the app's own sources (what a build reads): walk
|
|
562
|
+
* leaves the build folders out, and check/ is not built. */
|
|
535
563
|
function newestSource() {
|
|
536
564
|
let newest = 0;
|
|
537
565
|
for (const f of walk(ROOT)) {
|
|
538
|
-
|
|
539
|
-
if (/^(node_modules|\.next|\.next-check|check)\//.test(rel)) continue;
|
|
566
|
+
if (/^check\//.test(relative(ROOT, f))) continue;
|
|
540
567
|
newest = Math.max(newest, statSync(f).mtimeMs);
|
|
541
568
|
}
|
|
542
569
|
return newest;
|
|
@@ -571,7 +598,7 @@ function copySources(dir) {
|
|
|
571
598
|
dereference: false,
|
|
572
599
|
filter: (src) => {
|
|
573
600
|
const rel = relative(ROOT, src);
|
|
574
|
-
return !/^
|
|
601
|
+
return !notSource(rel) && !/^check(\/|$)/.test(rel);
|
|
575
602
|
},
|
|
576
603
|
});
|
|
577
604
|
const link = join(dir, "node_modules");
|
|
@@ -625,6 +652,12 @@ async function smoke() {
|
|
|
625
652
|
const home = await fetch(`${origin}/`, { redirect: "manual" });
|
|
626
653
|
results.push(`/ → ${home.status}`);
|
|
627
654
|
if (!(home.status === 200 || (home.status >= 300 && home.status < 400))) fail("smoke", "/", `answered ${home.status}`);
|
|
655
|
+
// The route app/auto-refresh.jsx asks before every refresh. Anything
|
|
656
|
+
// but its 204 and the app never refreshes again: its rows go stale
|
|
657
|
+
// while the agent writes them, and nothing on the page says so.
|
|
658
|
+
const alive = await fetch(`${origin}/claw-alive`, { redirect: "manual" });
|
|
659
|
+
results.push(`/claw-alive → ${alive.status}`);
|
|
660
|
+
if (alive.status !== 204) fail("smoke", "/claw-alive", `answered ${alive.status}, not 204: app/auto-refresh.jsx will never refresh this app. \`makersclaw update\` restores app/claw-alive/route.js`);
|
|
628
661
|
for (const key of surfaceKeys) {
|
|
629
662
|
const res = await fetch(`${origin}/s/${encodeURIComponent(key)}`);
|
|
630
663
|
const body = await res.text();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useMemo, useState } from "react";
|
|
3
|
+
import { createContext, useContext, useMemo, useState } from "react";
|
|
4
4
|
import { markSample } from "./sample";
|
|
5
5
|
import { Button, Notice } from "../ui";
|
|
6
6
|
|
|
@@ -20,18 +20,43 @@ import { Button, Notice } from "../ui";
|
|
|
20
20
|
//
|
|
21
21
|
// `rows` keeps its identity between renders (an effect may depend on it);
|
|
22
22
|
// declare SAMPLE at module level, not inside the component, so it does too.
|
|
23
|
+
//
|
|
24
|
+
// Examples show only OUTSIDE a Makersclaw workspace: the store's authoring
|
|
25
|
+
// loop, a shadcn project. Inside one, an app never shows example data (up
|
|
26
|
+
// to 0.3.x it did, until its table had a row): an empty table stays empty
|
|
27
|
+
// and the screen shows what its agent is doing instead. app/layout.jsx
|
|
28
|
+
// decides it per request and says so through <Examples allowed>; with no
|
|
29
|
+
// provider at all — a shadcn project — the answer is yes. The provider
|
|
30
|
+
// lives here, not in lib/, so the parts still travel as one folder.
|
|
23
31
|
|
|
24
32
|
const NONE = [];
|
|
33
|
+
const nothing = () => {};
|
|
34
|
+
|
|
35
|
+
const ExamplesContext = createContext(true);
|
|
36
|
+
|
|
37
|
+
/** Whether the parts below may show an example. The layout wraps the app
|
|
38
|
+
* in it; `allowed` is false inside a workspace. */
|
|
39
|
+
export function Examples({ allowed, children }) {
|
|
40
|
+
return <ExamplesContext.Provider value={Boolean(allowed)}>{children}</ExamplesContext.Provider>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** true unless an <Examples allowed={false}> is above. */
|
|
44
|
+
export function useExamples() {
|
|
45
|
+
return useContext(ExamplesContext);
|
|
46
|
+
}
|
|
25
47
|
|
|
26
48
|
export function useFixture(rows, sample, { idColumn = "id" } = {}) {
|
|
49
|
+
const allowed = useExamples();
|
|
27
50
|
const [cleared, setCleared] = useState(false);
|
|
28
|
-
const isFixture = !cleared && (!rows || rows.length === 0) && Array.isArray(sample) && sample.length > 0;
|
|
51
|
+
const isFixture = allowed && !cleared && (!rows || rows.length === 0) && Array.isArray(sample) && sample.length > 0;
|
|
29
52
|
const fixtureRows = useMemo(() => markSample(sample, idColumn), [sample, idColumn]);
|
|
53
|
+
// With examples off there is no example to clear or bring back: the
|
|
54
|
+
// real rows, however few, are all a surface gets.
|
|
30
55
|
return {
|
|
31
56
|
rows: isFixture ? fixtureRows : rows ?? NONE,
|
|
32
57
|
isFixture,
|
|
33
|
-
clear: () => setCleared(true),
|
|
34
|
-
restore: () => setCleared(false),
|
|
58
|
+
clear: allowed ? () => setCleared(true) : nothing,
|
|
59
|
+
restore: allowed ? () => setCleared(false) : nothing,
|
|
35
60
|
};
|
|
36
61
|
}
|
|
37
62
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Parts: the kit's pieces bound to this template's data layer. Each
|
|
2
2
|
// exists because an app got it wrong once; each carries the behaviour
|
|
3
3
|
// that makes it right. README §3b.
|
|
4
|
-
export { useFixture, isSample, FixtureNotice } from "./fixture";
|
|
4
|
+
export { Examples, useExamples, useFixture, isSample, FixtureNotice } from "./fixture";
|
|
5
|
+
export { LiveWork, LiveEmpty, liveWords } from "./live-work";
|
|
5
6
|
export { RecordSwitcher } from "./record-switcher";
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { isValidElement, useState } from "react";
|
|
4
|
+
import { Button, Icon, StatusDot, cx } from "../ui";
|
|
5
|
+
|
|
6
|
+
// The empty state that shows the work coming, never an example. Inside a
|
|
7
|
+
// Makersclaw workspace a table opens empty (fixture.jsx: examples show
|
|
8
|
+
// only outside one), and a table is usually empty because its agent has
|
|
9
|
+
// not got to it yet — or is on it right now. So the empty block says
|
|
10
|
+
// which: the ask in flight on this surface (its line, whose ask it is,
|
|
11
|
+
// what it waits behind, the one press it needs), or, with nothing in
|
|
12
|
+
// flight, what would appear here and the one way to make it. Up to 0.3.x
|
|
13
|
+
// an example filled that gap; since 0.4.0 no app shows example data in a
|
|
14
|
+
// workspace (claw's docs/JARVIS-PLATFORM.md §9.6 and §12, promise 6).
|
|
15
|
+
//
|
|
16
|
+
// const work = useRequests({ surface: "deals" }); // lib/hooks.js
|
|
17
|
+
// if (!rows.length) {
|
|
18
|
+
// return <LiveEmpty work={work} surface="deals" icon="inbox" title="No deals yet"
|
|
19
|
+
// line="Ask the agent to find some, or add one." action={{ label: "Add a deal", onClick: add }} />;
|
|
20
|
+
// }
|
|
21
|
+
//
|
|
22
|
+
// Props in, callbacks out: `work` is useRequests()'s answer as it stands,
|
|
23
|
+
// and the row calls back through it — work.open(request) opens the
|
|
24
|
+
// conversation in the desk's agent column (where a need is answered),
|
|
25
|
+
// work.stop(id) stops an ask. The part imports nothing from lib/, so the
|
|
26
|
+
// parts still travel as one folder; in a shadcn project, with no `work`,
|
|
27
|
+
// it is the plain empty block.
|
|
28
|
+
|
|
29
|
+
// A request's status is one of eleven (claw's shared/src/requests.ts;
|
|
30
|
+
// lib/hooks.js keeps the same list — this folder may not import it).
|
|
31
|
+
// Every step before the agent is on it reads as Working: the member asked
|
|
32
|
+
// for the result, not for its plumbing.
|
|
33
|
+
const WORDS = new Map([
|
|
34
|
+
["routing", "Working"],
|
|
35
|
+
["queued", "Working"],
|
|
36
|
+
["installing", "Working"],
|
|
37
|
+
["setting_up", "Working"],
|
|
38
|
+
["building", "Working"],
|
|
39
|
+
["working", "Working"],
|
|
40
|
+
["needs_you", "Needs you"],
|
|
41
|
+
["answered", "Answered"],
|
|
42
|
+
["done", "Done"],
|
|
43
|
+
["stopped", "Stopped"],
|
|
44
|
+
["didnt_finish", "Didn't finish"],
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** The seven statuses in which the work still goes. `answered` is open
|
|
48
|
+
* but not live: the agent answered and waits, and no row is coming. */
|
|
49
|
+
const LIVE = new Set(["routing", "queued", "installing", "setting_up", "building", "working", "needs_you"]);
|
|
50
|
+
|
|
51
|
+
/** A scheduled run's statuses while it goes (a brief's, not a request's:
|
|
52
|
+
* _claw/requests returns the run only in one of these). */
|
|
53
|
+
const RUN_LIVE = new Set(["queued", "planning", "awaiting_approval", "in_progress"]);
|
|
54
|
+
|
|
55
|
+
/** The status word for a request or a bare status: "Working", "Needs
|
|
56
|
+
* you", "Answered", "Done", "Stopped", "Didn't finish". An unknown
|
|
57
|
+
* status is "" — a word the platform has not taught this part yet is
|
|
58
|
+
* better unsaid than guessed. */
|
|
59
|
+
export function liveWords(requestOrStatus) {
|
|
60
|
+
const status = typeof requestOrStatus === "string" ? requestOrStatus : requestOrStatus?.status;
|
|
61
|
+
return WORDS.get(status) ?? "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runGoing(run) {
|
|
65
|
+
return Boolean(run && typeof run === "object" && (run.status == null || RUN_LIVE.has(run.status)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One piece of work in flight, as a row: a pulsing dot, the request's
|
|
70
|
+
* line, whose request it is when it is not yours, what it waits behind.
|
|
71
|
+
* A need gets the warning tone and Answer (it is answered in the desk's
|
|
72
|
+
* column, where the conversation lives); your own ask gets Stop. With no
|
|
73
|
+
* request, a scheduled run that is going shows its title.
|
|
74
|
+
*
|
|
75
|
+
* request one of useRequests().live (lib/hooks.js)
|
|
76
|
+
* run useRequests().run, shown when there is no request
|
|
77
|
+
* onOpen (request) → open its conversation; Answer needs it
|
|
78
|
+
* onStop (id) → stop it; Stop needs it, and shows only to the asker
|
|
79
|
+
*/
|
|
80
|
+
export function LiveWork({ request, run, onOpen, onStop, className }) {
|
|
81
|
+
const [stopping, setStopping] = useState(false);
|
|
82
|
+
|
|
83
|
+
if (request && typeof request === "object") {
|
|
84
|
+
const status = request.status;
|
|
85
|
+
const word = liveWords(request);
|
|
86
|
+
const needs = status === "needs_you";
|
|
87
|
+
const queued = status === "queued";
|
|
88
|
+
const theirs = request.mine === false;
|
|
89
|
+
const main = request.line || word || request.title || "";
|
|
90
|
+
const meta = [];
|
|
91
|
+
if (theirs) meta.push(`from ${request.asker_name || "a teammate"}'s request`);
|
|
92
|
+
// claw's line for a queued request already says "Queued · after …"
|
|
93
|
+
// (lineOf, shared/src/requests.ts); say it once, under a line that
|
|
94
|
+
// does not.
|
|
95
|
+
if (queued && !/^Queued\b/.test(main)) meta.push(`Queued · after ${request.queued?.after_title || "the job before it"}`);
|
|
96
|
+
// Only the asker answers: a teammate's need opens on "Waiting on …"
|
|
97
|
+
// in the desk, so it is shown, not offered.
|
|
98
|
+
const answer = needs && !theirs && onOpen;
|
|
99
|
+
const going = LIVE.has(status);
|
|
100
|
+
const stop = !needs && going && request.mine === true && onStop;
|
|
101
|
+
const onStopClick = async () => {
|
|
102
|
+
setStopping(true);
|
|
103
|
+
try {
|
|
104
|
+
await onStop(request.id);
|
|
105
|
+
} finally {
|
|
106
|
+
setStopping(false);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
return (
|
|
110
|
+
<Row
|
|
111
|
+
tone={needs ? "warning" : going && !queued ? "primary" : "neutral"}
|
|
112
|
+
pulse={going && !queued}
|
|
113
|
+
word={word}
|
|
114
|
+
warn={needs}
|
|
115
|
+
main={main}
|
|
116
|
+
meta={meta}
|
|
117
|
+
className={className}
|
|
118
|
+
>
|
|
119
|
+
{answer ? (
|
|
120
|
+
<Button size="sm" variant="primary" onClick={() => onOpen(request)}>
|
|
121
|
+
Answer
|
|
122
|
+
</Button>
|
|
123
|
+
) : stop ? (
|
|
124
|
+
<Button size="sm" variant="quiet" loading={stopping} onClick={onStopClick}>
|
|
125
|
+
Stop
|
|
126
|
+
</Button>
|
|
127
|
+
) : null}
|
|
128
|
+
</Row>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (runGoing(run)) {
|
|
133
|
+
return (
|
|
134
|
+
<Row tone="primary" pulse word="Working" main={run.title || "Working"} meta={run.title ? ["Working"] : []} className={className} />
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function Row({ tone, pulse, word, warn, main, meta, className, children }) {
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
className={cx(
|
|
144
|
+
"flex items-center gap-3 rounded-lg border bg-card px-3 py-2.5 text-left text-sm text-card-foreground",
|
|
145
|
+
warn ? "border-warning/50" : "border-border",
|
|
146
|
+
className,
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
<StatusDot tone={tone} pulse={pulse} label={word || "Working"} hideLabel />
|
|
150
|
+
<div aria-live="polite" className="min-w-0 flex-1">
|
|
151
|
+
<p className="truncate font-medium">{main}</p>
|
|
152
|
+
{meta.length ? <p className="truncate text-xs text-muted-foreground">{meta.join(" · ")}</p> : null}
|
|
153
|
+
</div>
|
|
154
|
+
{children ? <div className="flex shrink-0 items-center gap-2">{children}</div> : null}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Does a request belong to this empty state? Each filter given is a way
|
|
160
|
+
* in (lib/hooks.js reads it the same way): an ask made on this surface,
|
|
161
|
+
* or work on this job wherever it was asked for. No filter: any. */
|
|
162
|
+
function belongs(request, surface, job) {
|
|
163
|
+
if (!surface && !job) return true;
|
|
164
|
+
return Boolean((surface && request.surface === surface) || (job && request.job_key === job));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The one to show when several are in flight: what needs an answer, then
|
|
168
|
+
// what is working, then what waits in the queue; yours before a
|
|
169
|
+
// teammate's; the newest first.
|
|
170
|
+
const rank = (r) => (r.status === "needs_you" ? 0 : r.status === "queued" ? 2 : 1);
|
|
171
|
+
|
|
172
|
+
function pickRequest(live, surface, job) {
|
|
173
|
+
const list = (Array.isArray(live) ? live : [])
|
|
174
|
+
.map((r, i) => ({ r, i }))
|
|
175
|
+
.filter(({ r }) => r && typeof r === "object" && LIVE.has(r.status) && belongs(r, surface, job));
|
|
176
|
+
list.sort(
|
|
177
|
+
(a, b) =>
|
|
178
|
+
rank(a.r) - rank(b.r) ||
|
|
179
|
+
Number(b.r.mine === true) - Number(a.r.mine === true) ||
|
|
180
|
+
String(b.r.created_at ?? "").localeCompare(String(a.r.created_at ?? "")) ||
|
|
181
|
+
a.i - b.i,
|
|
182
|
+
);
|
|
183
|
+
return list[0]?.r ?? null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const FRAME = "grid place-items-center rounded-lg border border-dashed border-border px-6 py-10 text-center";
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* What an empty collection shows in a workspace. With work in flight for
|
|
190
|
+
* this surface or job — or the app's scheduled run going — the work, as
|
|
191
|
+
* a LiveWork row, and "Rows appear here as they land". With none, the
|
|
192
|
+
* empty block: icon, title, line and the one action that fills it.
|
|
193
|
+
*
|
|
194
|
+
* work useRequests()'s answer ({ live, run, open, stop }); absent
|
|
195
|
+
* in a shadcn project, where only the empty block shows
|
|
196
|
+
* surface the surface key its asks carry; job — the job key whose
|
|
197
|
+
* work lands here. Either is a way in; neither: any live work
|
|
198
|
+
* icon the empty block's glyph (inbox)
|
|
199
|
+
* title what would be here: "No deals yet"
|
|
200
|
+
* line how it gets here: "Ask the agent to find some, or add one."
|
|
201
|
+
* action { label, onClick, href, icon, variant } for a Button, or an
|
|
202
|
+
* element as it is
|
|
203
|
+
* children anything the surface adds under either state
|
|
204
|
+
*/
|
|
205
|
+
export function LiveEmpty({ work, surface, job, icon = "inbox", title, line, action, children, className }) {
|
|
206
|
+
const request = pickRequest(work?.live, surface, job);
|
|
207
|
+
const run = !request && runGoing(work?.run) ? work.run : null;
|
|
208
|
+
|
|
209
|
+
if (request || run) {
|
|
210
|
+
return (
|
|
211
|
+
<div className={cx(FRAME, "gap-3", className)}>
|
|
212
|
+
<LiveWork request={request} run={run} onOpen={work.open} onStop={work.stop} className="w-full max-w-md" />
|
|
213
|
+
<p className="text-sm text-muted-foreground">Rows appear here as they land</p>
|
|
214
|
+
{children}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const button = isValidElement(action) ? (
|
|
220
|
+
action
|
|
221
|
+
) : action && typeof action === "object" && action.label ? (
|
|
222
|
+
<Button variant={action.variant ?? "secondary"} icon={action.icon} href={action.href} onClick={action.onClick}>
|
|
223
|
+
{action.label}
|
|
224
|
+
</Button>
|
|
225
|
+
) : null;
|
|
226
|
+
return (
|
|
227
|
+
<div className={cx(FRAME, "gap-2", className)}>
|
|
228
|
+
<Icon name={icon} size={22} className="text-muted-foreground" />
|
|
229
|
+
{title ? <p className="text-sm font-medium">{title}</p> : null}
|
|
230
|
+
{line ? <p className="max-w-prose text-sm text-muted-foreground">{line}</p> : null}
|
|
231
|
+
{button ? <div className="mt-2">{button}</div> : null}
|
|
232
|
+
{children}
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
@@ -9,7 +9,12 @@ import { Spinner } from "./feedback";
|
|
|
9
9
|
// by hand in a surface: the look is the workspace's, the behaviour is
|
|
10
10
|
// the browser's, and this is the only place both meet.
|
|
11
11
|
//
|
|
12
|
-
// variant primary (the one main action per surface
|
|
12
|
+
// variant primary (the one main action per surface, filled with
|
|
13
|
+
// --primary, the app's accent) · secondary (the default, the
|
|
14
|
+
// workspace's neutral --secondary: a Button that does not say
|
|
15
|
+
// primary is never the accent — a surface's "Add guest"
|
|
16
|
+
// written without variant="primary" came out dark grey on a
|
|
17
|
+
// coral app, 2026-09-25) ·
|
|
13
18
|
// quiet (no fill) · danger (destructive, red text) ·
|
|
14
19
|
// link (text only: inline, no box, padding or min-height — it
|
|
15
20
|
// wraps and truncates with the sentence it sits in) · nav (a navigation row: quiet, full
|
|
@@ -139,6 +139,30 @@ export function formatDate(s, options = { dateStyle: "medium" }, locale) {
|
|
|
139
139
|
return d ? plainSpaces(new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" }).format(d)) : "";
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** Any date a row holds → a Date to show in the reader's own time; null
|
|
143
|
+
* when it is none. A day ("2026-09-28", what a `date` column reads as)
|
|
144
|
+
* is that day's LOCAL midnight: new Date("2026-09-28") is UTC's
|
|
145
|
+
* midnight, the 27th everywhere west of Greenwich, and a screen that
|
|
146
|
+
* shows it, then saves what it shows, moves the day back by one. A Date
|
|
147
|
+
* stays as it is; an ISO timestamp or epoch milliseconds go through
|
|
148
|
+
* new Date(). For a day alone, formatDate and the helpers above need no
|
|
149
|
+
* Date at all; this is for code that mixes days with instants (a
|
|
150
|
+
* comment's time, "due" beside "sent at"). */
|
|
151
|
+
export function toLocalDate(value) {
|
|
152
|
+
if (value === null || value === undefined || value === "") return null;
|
|
153
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
154
|
+
const day = typeof value === "string" ? parseDate(value) : null;
|
|
155
|
+
if (day) {
|
|
156
|
+
// setFullYear, not new Date(y, m, d): that one reads years 0–99 as 19xx.
|
|
157
|
+
const local = new Date(0);
|
|
158
|
+
local.setFullYear(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate());
|
|
159
|
+
local.setHours(0, 0, 0, 0);
|
|
160
|
+
return local;
|
|
161
|
+
}
|
|
162
|
+
const d = new Date(value);
|
|
163
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
164
|
+
}
|
|
165
|
+
|
|
142
166
|
// The week's first day for a locale: Intl's own answer where the browser
|
|
143
167
|
// has it (getWeekInfo), else CLDR's regions that start on Sunday or
|
|
144
168
|
// Saturday; everyone else starts on Monday.
|
|
@@ -275,6 +275,15 @@ export function DatePicker({
|
|
|
275
275
|
* value, onChange(date | null), defaultValue, min, max, disabled
|
|
276
276
|
* (true, or a function for the Calendar's days), name, invalid, size,
|
|
277
277
|
* locale (the Calendar's; the field itself follows the browser), today
|
|
278
|
+
* clearable an × beside the calendar button once the field holds a
|
|
279
|
+
* date (false — DatePicker's is true)
|
|
280
|
+
*
|
|
281
|
+
* Every other prop goes onto the <input>, so a prop this part names must
|
|
282
|
+
* be taken out here: until 0.3.3 `clearable` was DatePicker's alone, and
|
|
283
|
+
* a surface that wrote <DateInput clearable> put it on the <input> ("Received
|
|
284
|
+
* `true` for a non-boolean attribute clearable", a podcast guest sheet,
|
|
285
|
+
* 2026-09-25). It is DateInput's now too, off unless asked, so a field
|
|
286
|
+
* that did not ask keeps its width.
|
|
278
287
|
*/
|
|
279
288
|
export function DateInput({
|
|
280
289
|
id,
|
|
@@ -290,6 +299,7 @@ export function DateInput({
|
|
|
290
299
|
locale,
|
|
291
300
|
today,
|
|
292
301
|
weekStartsOn,
|
|
302
|
+
clearable = false,
|
|
293
303
|
className,
|
|
294
304
|
...rest
|
|
295
305
|
}) {
|
|
@@ -299,6 +309,7 @@ export function DateInput({
|
|
|
299
309
|
const [month, setMonth] = useState(null);
|
|
300
310
|
const off = disabled === true;
|
|
301
311
|
const out = !!value && ((min && value < min) || (max && value > max));
|
|
312
|
+
const showClear = clearable && !off && !!value;
|
|
302
313
|
|
|
303
314
|
function set(v) {
|
|
304
315
|
if (valueProp === undefined) setOwn(v);
|
|
@@ -318,12 +329,14 @@ export function DateInput({
|
|
|
318
329
|
invalid={invalid || out}
|
|
319
330
|
onChange={(e) => set(e.target.value || null)}
|
|
320
331
|
className={cx(
|
|
321
|
-
"pr-
|
|
332
|
+
showClear ? "pr-20" : "pr-12",
|
|
333
|
+
"[&::-webkit-calendar-picker-indicator]:hidden tabular-nums",
|
|
322
334
|
size === "sm" && "min-h-9",
|
|
323
335
|
)}
|
|
324
336
|
{...rest}
|
|
325
337
|
/>
|
|
326
|
-
<span className={cx("absolute right-1", size === "sm" ? "top-0" : "top-1")}>
|
|
338
|
+
<span className={cx("absolute right-1 flex", size === "sm" ? "top-0" : "top-1")}>
|
|
339
|
+
{showClear ? <IconButton icon="x" label="Clear date" size="sm" onClick={() => set(null)} /> : null}
|
|
327
340
|
<Popover
|
|
328
341
|
open={open}
|
|
329
342
|
onOpenChange={(next) => {
|
|
@@ -8,10 +8,19 @@ import { cx } from "./cx";
|
|
|
8
8
|
// validation, autofill). Wrap one in <Field> for its label, hint and
|
|
9
9
|
// error — never a bare <input> with a <div> label beside it.
|
|
10
10
|
|
|
11
|
-
const
|
|
12
|
-
"
|
|
11
|
+
const BOX =
|
|
12
|
+
"min-h-11 rounded-md border border-input bg-background px-3 text-sm text-foreground " +
|
|
13
13
|
"placeholder:text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring " +
|
|
14
14
|
"disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-destructive";
|
|
15
|
+
const CONTROL = `w-full ${BOX}`;
|
|
16
|
+
|
|
17
|
+
// An Input is as wide as its column unless the caller's className gives
|
|
18
|
+
// it a width (w-24, w-auto, w-[12rem], size-10), which then stands in
|
|
19
|
+
// for w-full. cx does not merge classes: with both, the rule later in the
|
|
20
|
+
// stylesheet wins, and Tailwind writes w-full after w-24, w-auto, w-fit
|
|
21
|
+
// and w-[…] — until 0.5.1 those did nothing on an Input. A width under a
|
|
22
|
+
// breakpoint (sm:w-40) keeps w-full below it.
|
|
23
|
+
const ownWidth = (className) => /(?:^|\s)!?(?:w|size)-/.test(className ?? "");
|
|
15
24
|
|
|
16
25
|
/** Label, control, hint and error in one stack. Pass `id`; the label
|
|
17
26
|
* points at it. `error` (a string) marks the control invalid. */
|
|
@@ -39,7 +48,7 @@ export function Field({ id, label, hint, error, required, className, children })
|
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
export function Input({ className, invalid, ...rest }) {
|
|
42
|
-
return <input className={cx(
|
|
51
|
+
return <input className={cx(!ownWidth(className) && "w-full", BOX, className)} aria-invalid={invalid ? true : undefined} {...rest} />;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
/**
|
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
// one here, never an <svg> in a surface. `name` is a key below; unknown
|
|
4
4
|
// names render nothing (and `npm run check` names them).
|
|
5
5
|
//
|
|
6
|
+
// `size` is pixels (16, the default) or one of the kit's size names:
|
|
7
|
+
// xs 12 · sm 14 · md 16 · lg 20 · xl 24. Every other part of the kit
|
|
8
|
+
// takes size="sm", so a surface writes it here too — and until 0.3.3 a
|
|
9
|
+
// name went onto the <svg> as width="sm", which is not a length: the
|
|
10
|
+
// browser logged "Expected length, 'sm'" and drew the glyph at its
|
|
11
|
+
// 300×150 default, a calendar as big as the card around it (a podcast
|
|
12
|
+
// guest board, 2026-09-25).
|
|
13
|
+
//
|
|
14
|
+
// A glyph added for a build takes lucide's name when lucide has one
|
|
15
|
+
// (calendar-check, trash-2), because that is the name a build reaches for
|
|
16
|
+
// first; the path is our own, drawn to the same 24 box, never copied.
|
|
17
|
+
//
|
|
6
18
|
// BRANDS holds the brand marks: google, github, apple, microsoft,
|
|
7
19
|
// x-logo (the X / Twitter mark; `x` is the close glyph), linkedin,
|
|
8
20
|
// instagram, youtube, facebook. Unlike PATHS they are FILLED shapes
|
|
@@ -167,6 +179,13 @@ const PATHS = {
|
|
|
167
179
|
kanban: "M4 4h4v14H4zM10 4h4v9h-4zM16 4h4v16h-4z",
|
|
168
180
|
table: "M3 4h18v16H3zM3 10h18M3 15h18M9 10v10",
|
|
169
181
|
gantt: "M4 4v16h16M8 8h6M11 12h7M9 16h5",
|
|
182
|
+
// Names builds reached for that the sprite lacked, so `npm run check`
|
|
183
|
+
// refused the ship and the build spent a round renaming them (the
|
|
184
|
+
// launch build test, 2026-09-25, and the local build logs before it):
|
|
185
|
+
// a day that is booked, the bin with its slats, a small tree of nodes.
|
|
186
|
+
"calendar-check": "M3 5h18v16H3zM16 3v4M8 3v4M3 10h18M9 15.5l2 2 4-4",
|
|
187
|
+
"trash-2": "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14M10 11v6M14 11v6",
|
|
188
|
+
network: "M9 2h6v6H9zM2 16h6v6H2zM16 16h6v6h-6zM12 8v4M5 16v-4h14v4",
|
|
170
189
|
};
|
|
171
190
|
|
|
172
191
|
const BRANDS = {
|
|
@@ -183,14 +202,30 @@ const BRANDS = {
|
|
|
183
202
|
|
|
184
203
|
export const ICON_NAMES = [...Object.keys(PATHS), ...Object.keys(BRANDS)];
|
|
185
204
|
|
|
205
|
+
// The kit's size names in pixels. A number, or a length the <svg> can
|
|
206
|
+
// read ("18", "1.25em"), is kept as it was; anything else is the default,
|
|
207
|
+
// never an attribute the browser cannot read.
|
|
208
|
+
const SIZES = { xs: 12, sm: 14, md: 16, lg: 20, xl: 24 };
|
|
209
|
+
const LENGTH = /^(\d+\.?\d*|\.\d+)(px|em|rem|%)?$/;
|
|
210
|
+
|
|
211
|
+
function pixels(size) {
|
|
212
|
+
if (typeof size === "number" && size > 0) return size;
|
|
213
|
+
if (typeof size === "string") {
|
|
214
|
+
if (SIZES[size]) return SIZES[size];
|
|
215
|
+
if (LENGTH.test(size)) return size;
|
|
216
|
+
}
|
|
217
|
+
return SIZES.md;
|
|
218
|
+
}
|
|
219
|
+
|
|
186
220
|
export function Icon({ name, size = 16, className, label }) {
|
|
187
221
|
const brand = BRANDS[name];
|
|
188
222
|
const d = PATHS[name] || brand;
|
|
189
223
|
if (!d) return null;
|
|
224
|
+
const px = pixels(size);
|
|
190
225
|
return (
|
|
191
226
|
<svg
|
|
192
|
-
width={
|
|
193
|
-
height={
|
|
227
|
+
width={px}
|
|
228
|
+
height={px}
|
|
194
229
|
viewBox="0 0 24 24"
|
|
195
230
|
fill={brand ? "currentColor" : "none"}
|
|
196
231
|
fillRule={brand ? "evenodd" : undefined}
|