@spunto/design-system 0.10.0 → 0.11.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 +85 -3
- package/package.json +9 -1
- package/src/components/devcontainer/catalog-card.tsx +269 -0
- package/src/components/devcontainer/catalog-tile.tsx +133 -0
- package/src/components/devcontainer/extension-card.tsx +146 -0
- package/src/components/devcontainer/feature-card.tsx +157 -0
- package/src/components/devcontainer/feature-tile.tsx +63 -0
- package/src/components/devcontainer/image-card.tsx +107 -0
- package/src/components/devcontainer/image-tile.tsx +56 -0
- package/src/components/devcontainer/index.ts +68 -0
- package/src/components/devcontainer/publisher-badge.tsx +91 -0
- package/src/components/devcontainer/registry-ref.tsx +89 -0
- package/src/components/devcontainer/registry.ts +154 -0
- package/src/components/devcontainer/stack-mark-paths.ts +60 -0
- package/src/components/devcontainer/stack-mark.tsx +92 -0
- package/src/components/devcontainer/types.ts +84 -0
- package/src/components/projects/advanced-sections.tsx +148 -0
- package/src/components/projects/build-manifest.tsx +116 -0
- package/src/components/projects/extension-picker.tsx +169 -0
- package/src/components/projects/feature-picker.tsx +193 -0
- package/src/components/projects/form-section.tsx +97 -0
- package/src/components/projects/image-picker.tsx +206 -0
- package/src/components/projects/index.ts +58 -0
- package/src/components/projects/project-form.tsx +495 -0
- package/src/components/projects/repo-list.tsx +302 -0
- package/src/components/projects/secret-list.tsx +159 -0
- package/src/components/projects/types.ts +136 -0
- package/styles.css +13 -0
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
|
|
|
89
89
|
provider is also the umbrella that mounts the tooltip delay group and the overlay
|
|
90
90
|
portal container the dialogs/selects render into.
|
|
91
91
|
|
|
92
|
-
## Domain components —
|
|
92
|
+
## Domain components — `/workers`, `/devcontainer`, `/projects`
|
|
93
93
|
|
|
94
94
|
Everything above is domain-free: a `Button` knows nothing about Spunto. Components
|
|
95
95
|
that **do** know a Spunto concept live behind their own entry point, so the root
|
|
@@ -98,10 +98,14 @@ of a worker. **The rule for what comes next: any new component that knows a doma
|
|
|
98
98
|
concept goes behind a domain sub-export, never in the root entry.**
|
|
99
99
|
|
|
100
100
|
```tsx
|
|
101
|
-
import { Button, Card } from "@spunto/design-system"
|
|
102
|
-
import { WorkerCard } from "@spunto/design-system/workers"
|
|
101
|
+
import { Button, Card } from "@spunto/design-system" // primitives
|
|
102
|
+
import { WorkerCard } from "@spunto/design-system/workers" // domain
|
|
103
|
+
import { ImageCard, FeatureCard, ExtensionCard } from "@spunto/design-system/devcontainer" // domain
|
|
104
|
+
import { ProjectForm } from "@spunto/design-system/projects" // domain
|
|
103
105
|
```
|
|
104
106
|
|
|
107
|
+
### `@spunto/design-system/workers`
|
|
108
|
+
|
|
105
109
|
- **`WorkerCard`** — a Spunto worker as a card: state, author, node, setup
|
|
106
110
|
progress, git branches, resources, banners, actions. **Purely presentational** —
|
|
107
111
|
hand it a snapshot plus slots and it draws. The rule that keeps it that way:
|
|
@@ -119,6 +123,84 @@ import { WorkerCard } from "@spunto/design-system/workers" // domain
|
|
|
119
123
|
minimum, everything optional but `id`, and an unknown state falls back to
|
|
120
124
|
`pending` instead of throwing.
|
|
121
125
|
|
|
126
|
+
### `@spunto/design-system/devcontainer`
|
|
127
|
+
|
|
128
|
+
The three catalogs you compose an environment from. They exist identically in the
|
|
129
|
+
dashboard and in Spunto Lite, and were hand-drawn as raw `<button>`/`<li>` on both
|
|
130
|
+
sides — exactly the duplication this package is for.
|
|
131
|
+
|
|
132
|
+
- **`ImageCard`** — a devcontainer base image: runtime mark, label, publisher +
|
|
133
|
+
avatar, image reference in mono, description.
|
|
134
|
+
- **`FeatureCard`** — a devcontainer feature: the author's avatar (the GitHub org
|
|
135
|
+
behind the `ghcr.io` ref), label, description, option chips (`version=lts`…),
|
|
136
|
+
OCI reference.
|
|
137
|
+
- **`ImageTile` / `FeatureTile`** (+ the shared `CatalogTile`) — the same entries
|
|
138
|
+
at chip size: a mark, a name, one trailing detail. What a catalog needs past a
|
|
139
|
+
dozen entries, where picking is a *logo* task and full cards become a wall.
|
|
140
|
+
They flow and wrap like tags; the pickers pair them with the full card of
|
|
141
|
+
what's selected.
|
|
142
|
+
- **`ExtensionCard`** (+ `ExtensionCardSkeleton`) — a VS Code extension: its real
|
|
143
|
+
Open VSX icon, display name, publisher + verified badge, version, formatted
|
|
144
|
+
install count, rating.
|
|
145
|
+
|
|
146
|
+
All three share one selectable state (flame check + ring), one **compact
|
|
147
|
+
read-only** variant for the project sheet, and one **`custom`** variant for a
|
|
148
|
+
hand-typed reference that degrades cleanly.
|
|
149
|
+
|
|
150
|
+
- **Nothing here fetches anything.** `onSelect` is a callback, the option row
|
|
151
|
+
(`optionsSlot`) and the trailing control (`action`) are slots. The author and
|
|
152
|
+
the logo are **derived from the reference** by pure functions, because the
|
|
153
|
+
catalogs carry neither: `parseRegistryRef`, `derivePublisher`
|
|
154
|
+
(`ghcr.io/<owner>/…` → that GitHub org, avatar included via
|
|
155
|
+
`github.com/<owner>.png`; `mcr.microsoft.com/devcontainers/…` → Dev Containers)
|
|
156
|
+
and `resolveStackMark`. `publisher` / `mark` / `iconUrl` are the overrides for
|
|
157
|
+
the day the API knows better — no component change needed then.
|
|
158
|
+
- **Brand marks ship with the package** as single-path SVGs drawn with
|
|
159
|
+
`currentColor` (`StackMark`, `STACK_MARK_PATHS`): no third-party CDN, and each
|
|
160
|
+
logo inherits the warm accent of its card instead of dragging 20 clashing brand
|
|
161
|
+
palettes into one grid.
|
|
162
|
+
- **Its bricks, exported individually**: `CatalogCard` (the selectable shell),
|
|
163
|
+
`CatalogMark` (the icon → brand → avatar → monogram cascade), `CatalogChip`,
|
|
164
|
+
`PublisherBadge`/`PublisherAvatar`, `RegistryRef` (mono + copy button), plus the
|
|
165
|
+
pure helpers `parseRegistryRef`, `derivePublisher`, `githubAvatarUrl`,
|
|
166
|
+
`resolveStackMark`, `resolveFeatureOptions`, `formatCount`, `parseExtensionId`.
|
|
167
|
+
- **Types are structural** here too: `DevcontainerImageEntry`,
|
|
168
|
+
`DevcontainerFeatureEntry` and `VscodeExtensionEntry` are all-optional, and an
|
|
169
|
+
unknown registry degrades to a monogram instead of an invented author.
|
|
170
|
+
|
|
171
|
+
### `@spunto/design-system/projects`
|
|
172
|
+
|
|
173
|
+
- **`ProjectForm`** — the whole "compose a dev environment" screen: identity,
|
|
174
|
+
base image, repos, features, extensions, lifecycle, ports, prewarm images,
|
|
175
|
+
docker-in-docker, secrets, plus the live **build manifest** that recaps it and
|
|
176
|
+
carries the submit button.
|
|
177
|
+
- **Controlled by one value.** A single `ProjectFormValue` in, one `onChange`
|
|
178
|
+
out, `onSubmit(value)` at the end. Start from `emptyProjectFormValue()`, or
|
|
179
|
+
`toProjectFormValue(partial)` to fill the gaps of an existing project. The
|
|
180
|
+
value is *always* readable — which is what makes validation, drafts and
|
|
181
|
+
previews possible at all.
|
|
182
|
+
- **Which sections show is a prop** (`CREATE_SECTIONS` / `EDIT_SECTIONS` / your
|
|
183
|
+
own list, in your own order). Creation and editing are the same component with
|
|
184
|
+
a different list, not two screens that drift apart.
|
|
185
|
+
- **Everything past identity / base image / repos folds under "Advanced
|
|
186
|
+
options"** (`advancedSections`, `[]` to disable). Two rules keep that honest:
|
|
187
|
+
the closed fold *writes out what it holds* ("2 features · lifecycle · 3
|
|
188
|
+
secrets") instead of showing a bare chevron, and the form opens it by itself
|
|
189
|
+
when any of those sections already has a value — so editing a project never
|
|
190
|
+
hides a setting you made earlier.
|
|
191
|
+
- **Nothing fetches.** Catalogs come in as data, the marketplace search is
|
|
192
|
+
`onSearchExtensions`, the GitHub combobox is `renderRepoField` (without it: a
|
|
193
|
+
plain `<input list>` + `<datalist>`, no dependency, keyboard-friendly), and
|
|
194
|
+
`extras` slips app-specific content *inside* a section — the deploy key card,
|
|
195
|
+
a warning — without forking the component.
|
|
196
|
+
- **Its bricks, exported individually**: `FormSection`, `Field`,
|
|
197
|
+
`BuildManifest`, `RepoList` (+`deriveFromGitUrl`), `SecretList`,
|
|
198
|
+
`ImagePicker`/`FeaturePicker`/`ExtensionPicker`, and the pure `manifestRows`.
|
|
199
|
+
- **Responsive by container query, not viewport.** The form is a full page in
|
|
200
|
+
one app and a 700 px panel in another; it reads its own width to decide
|
|
201
|
+
whether the manifest sits beside it and whether the catalogs are one column or
|
|
202
|
+
two.
|
|
203
|
+
|
|
122
204
|
## Toasts — `SpuntoProvider` + `toast()`
|
|
123
205
|
|
|
124
206
|
`SpuntoProvider` is the design system's client umbrella provider. Mount it once
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,14 @@
|
|
|
37
37
|
"types": "./src/components/workers/index.ts",
|
|
38
38
|
"import": "./src/components/workers/index.ts"
|
|
39
39
|
},
|
|
40
|
+
"./devcontainer": {
|
|
41
|
+
"types": "./src/components/devcontainer/index.ts",
|
|
42
|
+
"import": "./src/components/devcontainer/index.ts"
|
|
43
|
+
},
|
|
44
|
+
"./projects": {
|
|
45
|
+
"types": "./src/components/projects/index.ts",
|
|
46
|
+
"import": "./src/components/projects/index.ts"
|
|
47
|
+
},
|
|
40
48
|
"./fonts": {
|
|
41
49
|
"types": "./src/components/fonts.tsx",
|
|
42
50
|
"import": "./src/components/fonts.tsx"
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react"
|
|
4
|
+
import { CheckIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { monogram, PublisherAvatar } from "./publisher-badge"
|
|
8
|
+
import { StackMark, type StackMarkName } from "./stack-mark"
|
|
9
|
+
import type { CatalogPublisher } from "./types"
|
|
10
|
+
|
|
11
|
+
/** Warm accents the catalogs use — one per pillar, plus the brand flame. */
|
|
12
|
+
export type CatalogTone = "build" | "ship" | "run" | "flame"
|
|
13
|
+
|
|
14
|
+
const TONE_TILE: Record<CatalogTone, string> = {
|
|
15
|
+
build: "bg-build/10 text-build",
|
|
16
|
+
ship: "bg-ship/10 text-ship",
|
|
17
|
+
run: "bg-run/10 text-run",
|
|
18
|
+
flame: "bg-primary/10 text-primary",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TONE_WATERMARK: Record<CatalogTone, string> = {
|
|
22
|
+
build: "text-build",
|
|
23
|
+
ship: "text-ship",
|
|
24
|
+
run: "text-run",
|
|
25
|
+
flame: "text-primary",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CatalogMarkProps {
|
|
29
|
+
/** A real logo (an extension icon, say). Beats every derived mark. */
|
|
30
|
+
iconUrl?: string | null
|
|
31
|
+
/** Embedded brand mark, when one could be derived. */
|
|
32
|
+
mark?: StackMarkName | string | null
|
|
33
|
+
/** Used as the mark when no brand matched — an author's avatar still says something. */
|
|
34
|
+
publisher?: CatalogPublisher
|
|
35
|
+
/** Last resort: initials. */
|
|
36
|
+
label?: string
|
|
37
|
+
tone?: CatalogTone
|
|
38
|
+
compact?: boolean
|
|
39
|
+
className?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The tile at the left of every catalog card, resolved by a single cascade —
|
|
44
|
+
* **real icon → brand mark → publisher avatar → monogram**. Each catalog has a
|
|
45
|
+
* different amount of truth available (Open VSX ships an icon, ghcr.io only
|
|
46
|
+
* implies a GitHub org, a hand-typed ref implies nothing), and this cascade is
|
|
47
|
+
* what makes all three grids look like one product anyway.
|
|
48
|
+
*/
|
|
49
|
+
export function CatalogMark({
|
|
50
|
+
iconUrl,
|
|
51
|
+
mark,
|
|
52
|
+
publisher,
|
|
53
|
+
label,
|
|
54
|
+
tone = "build",
|
|
55
|
+
compact = false,
|
|
56
|
+
className,
|
|
57
|
+
}: CatalogMarkProps) {
|
|
58
|
+
const [broken, setBroken] = useState(false)
|
|
59
|
+
const box = compact ? "h-5 w-5 rounded" : "h-9 w-9 rounded-md"
|
|
60
|
+
|
|
61
|
+
if (iconUrl && !broken) {
|
|
62
|
+
return (
|
|
63
|
+
<img
|
|
64
|
+
src={iconUrl}
|
|
65
|
+
alt=""
|
|
66
|
+
loading="lazy"
|
|
67
|
+
onError={() => setBroken(true)}
|
|
68
|
+
className={cn(box, "shrink-0 bg-muted object-contain ring-1 ring-border/60", className)}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (mark) {
|
|
74
|
+
return (
|
|
75
|
+
<span className={cn(box, "flex shrink-0 items-center justify-center", TONE_TILE[tone], className)}>
|
|
76
|
+
<StackMark name={mark} className={compact ? "h-3 w-3" : "h-5 w-5"} />
|
|
77
|
+
</span>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (publisher?.avatarUrl) {
|
|
82
|
+
return <PublisherAvatar publisher={publisher} fallbackLabel={label} className={cn(box, "rounded-md", className)} />
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<span
|
|
87
|
+
aria-hidden
|
|
88
|
+
className={cn(
|
|
89
|
+
box,
|
|
90
|
+
"flex shrink-0 items-center justify-center font-semibold",
|
|
91
|
+
compact ? "text-[8px]" : "text-[11px]",
|
|
92
|
+
TONE_TILE[tone],
|
|
93
|
+
className
|
|
94
|
+
)}
|
|
95
|
+
>
|
|
96
|
+
{monogram(label || publisher?.name || publisher?.login)}
|
|
97
|
+
</span>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface CatalogCardProps {
|
|
102
|
+
/** The tile — usually a `CatalogMark`. */
|
|
103
|
+
mark?: ReactNode
|
|
104
|
+
title: ReactNode
|
|
105
|
+
/** Same line as the title, right after it: a version, a tag. */
|
|
106
|
+
titleMeta?: ReactNode
|
|
107
|
+
/** The publisher line, under the title. */
|
|
108
|
+
meta?: ReactNode
|
|
109
|
+
description?: ReactNode
|
|
110
|
+
/** Chips, refs — anything under the description. */
|
|
111
|
+
children?: ReactNode
|
|
112
|
+
|
|
113
|
+
selected?: boolean
|
|
114
|
+
/**
|
|
115
|
+
* Provided → the card is selectable (a stretched button covers it, and the
|
|
116
|
+
* flame check + ring appear when `selected`). Absent → a plain read-only
|
|
117
|
+
* card, which is exactly what the project sheet needs.
|
|
118
|
+
*/
|
|
119
|
+
onSelect?: () => void
|
|
120
|
+
disabled?: boolean
|
|
121
|
+
|
|
122
|
+
/** One-line, denser rendering: no description, no watermark. */
|
|
123
|
+
compact?: boolean
|
|
124
|
+
/** Nothing was derivable (a hand-typed ref) → dashed border. */
|
|
125
|
+
custom?: boolean
|
|
126
|
+
|
|
127
|
+
/** Trailing control (a remove ✕). Sits above the selection overlay. */
|
|
128
|
+
action?: ReactNode
|
|
129
|
+
/** Brand mark bled large and faint into the background. */
|
|
130
|
+
watermark?: StackMarkName | string | null
|
|
131
|
+
tone?: CatalogTone
|
|
132
|
+
/** Accessible name of the selection button when the title isn't a string. */
|
|
133
|
+
selectLabel?: string
|
|
134
|
+
className?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The shell all three catalog cards share: tile, title, meta, description,
|
|
139
|
+
* extras — plus the selected state (flame check + ring) that the three pickers
|
|
140
|
+
* each used to re-implement in a `<button>` of their own.
|
|
141
|
+
*
|
|
142
|
+
* **Why the selection is a stretched overlay button rather than a `<button>`
|
|
143
|
+
* wrapping everything:** these cards carry controls of their own (copy the ref,
|
|
144
|
+
* remove a custom entry), and a button inside a button is invalid HTML —
|
|
145
|
+
* browsers unnest it and the inner control stops working. So the card is a
|
|
146
|
+
* `<div>`, selection is one `absolute inset-0` button covering it, and inner
|
|
147
|
+
* controls sit above that overlay on `z-10`. The card's own text is left
|
|
148
|
+
* *static* on purpose: static content paints below positioned siblings, so the
|
|
149
|
+
* overlay covers it and a click anywhere on the card still selects it.
|
|
150
|
+
*/
|
|
151
|
+
export function CatalogCard({
|
|
152
|
+
mark,
|
|
153
|
+
title,
|
|
154
|
+
titleMeta,
|
|
155
|
+
meta,
|
|
156
|
+
description,
|
|
157
|
+
children,
|
|
158
|
+
selected = false,
|
|
159
|
+
onSelect,
|
|
160
|
+
disabled = false,
|
|
161
|
+
compact = false,
|
|
162
|
+
custom = false,
|
|
163
|
+
action,
|
|
164
|
+
watermark,
|
|
165
|
+
tone = "build",
|
|
166
|
+
selectLabel,
|
|
167
|
+
className,
|
|
168
|
+
}: CatalogCardProps) {
|
|
169
|
+
const selectable = Boolean(onSelect)
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div
|
|
173
|
+
data-slot="catalog-card"
|
|
174
|
+
data-selected={selected || undefined}
|
|
175
|
+
// `isolate`: the watermark sits on a negative z-index so it stays behind
|
|
176
|
+
// the text, and without a stacking context here it would slide behind the
|
|
177
|
+
// card's own background instead.
|
|
178
|
+
className={cn(
|
|
179
|
+
"group relative isolate flex min-w-0 items-start gap-3 overflow-hidden border bg-card text-left transition-colors",
|
|
180
|
+
compact ? "gap-2 rounded-md px-2 py-1.5" : "rounded-lg px-3 py-2.5",
|
|
181
|
+
custom ? "border-dashed border-border" : "border-border",
|
|
182
|
+
selected && "border-primary bg-primary/5 ring-1 ring-primary",
|
|
183
|
+
selectable && !selected && !disabled && "hover:border-muted-foreground/40 hover:bg-muted/50",
|
|
184
|
+
disabled && "opacity-50",
|
|
185
|
+
className
|
|
186
|
+
)}
|
|
187
|
+
>
|
|
188
|
+
{/* The runtime's mark, bled large into the corner and almost invisible.
|
|
189
|
+
It's texture, not information — it's what lets you tell a Python card
|
|
190
|
+
from a Go one out of the corner of your eye, in a grid where every
|
|
191
|
+
card is otherwise the same three lines of text. */}
|
|
192
|
+
{watermark && !compact && (
|
|
193
|
+
<span
|
|
194
|
+
aria-hidden
|
|
195
|
+
className={cn(
|
|
196
|
+
"pointer-events-none absolute -top-5 -right-6 -z-10 transition-opacity",
|
|
197
|
+
selected ? "opacity-[0.08]" : "opacity-[0.04] group-hover:opacity-[0.06]",
|
|
198
|
+
TONE_WATERMARK[tone]
|
|
199
|
+
)}
|
|
200
|
+
>
|
|
201
|
+
<StackMark name={watermark} className="h-28 w-28" />
|
|
202
|
+
</span>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{selectable && (
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={onSelect}
|
|
209
|
+
disabled={disabled}
|
|
210
|
+
aria-pressed={selected}
|
|
211
|
+
aria-label={selectLabel ?? (typeof title === "string" ? title : undefined)}
|
|
212
|
+
className={cn(
|
|
213
|
+
"absolute inset-0 cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed",
|
|
214
|
+
compact ? "rounded-md" : "rounded-lg"
|
|
215
|
+
)}
|
|
216
|
+
/>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{mark && <span className="mt-px">{mark}</span>}
|
|
220
|
+
|
|
221
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
222
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
223
|
+
<span className={cn("min-w-0 truncate font-medium leading-tight", compact ? "text-xs" : "text-sm")}>
|
|
224
|
+
{title}
|
|
225
|
+
</span>
|
|
226
|
+
{titleMeta}
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{meta && <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">{meta}</div>}
|
|
230
|
+
|
|
231
|
+
{description && !compact && <p className="line-clamp-2 text-xs text-muted-foreground">{description}</p>}
|
|
232
|
+
|
|
233
|
+
{children}
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
{/* The check sits in the flow rather than pinned absolutely: at 288 px a
|
|
237
|
+
title wrapping onto two lines used to slide under a pinned check. */}
|
|
238
|
+
{(selected || action) && (
|
|
239
|
+
<span className="relative z-10 flex shrink-0 items-center gap-1 self-start pt-px">
|
|
240
|
+
{selected && <CheckIcon className={cn("text-primary", compact ? "h-3.5 w-3.5" : "h-4 w-4")} aria-hidden />}
|
|
241
|
+
{action}
|
|
242
|
+
</span>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Chip for a feature option, an extension version, a download count. */
|
|
249
|
+
export function CatalogChip({
|
|
250
|
+
children,
|
|
251
|
+
title,
|
|
252
|
+
className,
|
|
253
|
+
}: {
|
|
254
|
+
children: ReactNode
|
|
255
|
+
title?: string
|
|
256
|
+
className?: string
|
|
257
|
+
}) {
|
|
258
|
+
return (
|
|
259
|
+
<span
|
|
260
|
+
title={title}
|
|
261
|
+
className={cn(
|
|
262
|
+
"inline-flex max-w-full items-center gap-1 truncate rounded border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-[10px] leading-none text-muted-foreground",
|
|
263
|
+
className
|
|
264
|
+
)}
|
|
265
|
+
>
|
|
266
|
+
{children}
|
|
267
|
+
</span>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { CheckIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import type { CatalogTone } from "./catalog-card"
|
|
8
|
+
|
|
9
|
+
const TONE = {
|
|
10
|
+
build: "text-build",
|
|
11
|
+
ship: "text-ship",
|
|
12
|
+
run: "text-run",
|
|
13
|
+
flame: "text-primary",
|
|
14
|
+
} as const satisfies Record<CatalogTone, string>
|
|
15
|
+
|
|
16
|
+
export interface CatalogTileProps {
|
|
17
|
+
/** The glyph — a brand mark, an avatar, a `+`. Sized by the tile. */
|
|
18
|
+
mark: ReactNode
|
|
19
|
+
label: string
|
|
20
|
+
/** Trailing detail: an image tag, a version. Dropped in `tile` when it doesn't fit. */
|
|
21
|
+
meta?: string
|
|
22
|
+
/** Full text for the native tooltip — the label truncates. */
|
|
23
|
+
title?: string
|
|
24
|
+
|
|
25
|
+
selected?: boolean
|
|
26
|
+
onSelect?: () => void
|
|
27
|
+
disabled?: boolean
|
|
28
|
+
custom?: boolean
|
|
29
|
+
variant?: "chip" | "tile"
|
|
30
|
+
tone?: CatalogTone
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The shell behind `ImageTile` and `FeatureTile`: a catalog entry reduced to a
|
|
36
|
+
* glyph, a name and one trailing detail.
|
|
37
|
+
*
|
|
38
|
+
* Extracted rather than copied, because the two only differ in what they *put*
|
|
39
|
+
* in it — an image derives a runtime mark and a tag, a feature derives its
|
|
40
|
+
* author's avatar — and this package exists to stop that kind of near-duplicate
|
|
41
|
+
* from drifting apart.
|
|
42
|
+
*
|
|
43
|
+
* `chip` flows and wraps like a tag; `tile` is a square-ish grid cell. Both
|
|
44
|
+
* carry the same flame check as the full cards, because a chip grid and a card
|
|
45
|
+
* grid are the same choice and "this one is picked" has to look the same.
|
|
46
|
+
*/
|
|
47
|
+
export function CatalogTile({
|
|
48
|
+
mark,
|
|
49
|
+
label,
|
|
50
|
+
meta,
|
|
51
|
+
title,
|
|
52
|
+
selected = false,
|
|
53
|
+
onSelect,
|
|
54
|
+
disabled = false,
|
|
55
|
+
custom = false,
|
|
56
|
+
variant = "chip",
|
|
57
|
+
tone = "ship",
|
|
58
|
+
className,
|
|
59
|
+
}: CatalogTileProps) {
|
|
60
|
+
const Root = onSelect ? "button" : "div"
|
|
61
|
+
|
|
62
|
+
const common = {
|
|
63
|
+
"data-slot": "catalog-tile",
|
|
64
|
+
"data-selected": selected || undefined,
|
|
65
|
+
"data-variant": variant,
|
|
66
|
+
title: title ?? label,
|
|
67
|
+
...(onSelect
|
|
68
|
+
? { type: "button" as const, onClick: onSelect, disabled, "aria-pressed": selected, "aria-label": label }
|
|
69
|
+
: {}),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const state = cn(
|
|
73
|
+
custom ? "border-dashed border-border" : "border-border",
|
|
74
|
+
selected ? "border-primary bg-primary/5 ring-1 ring-primary" : "bg-card",
|
|
75
|
+
onSelect && !disabled && "cursor-pointer",
|
|
76
|
+
onSelect && !selected && !disabled && "hover:border-muted-foreground/40 hover:bg-muted/50",
|
|
77
|
+
disabled && "cursor-not-allowed opacity-50"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const markClass = cn(
|
|
81
|
+
"flex shrink-0 items-center justify-center transition-colors",
|
|
82
|
+
selected ? TONE[tone] : "text-muted-foreground group-hover:text-foreground"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if (variant === "chip") {
|
|
86
|
+
return (
|
|
87
|
+
<Root
|
|
88
|
+
{...common}
|
|
89
|
+
className={cn(
|
|
90
|
+
// `inline-flex` + the parent's `flex-wrap`: entries flow and break
|
|
91
|
+
// onto the next line like text, so a catalog of any length just takes
|
|
92
|
+
// the rows it needs — no column count to choose, no ragged grid.
|
|
93
|
+
"group inline-flex max-w-full min-w-0 items-center gap-2 rounded-md border py-1.5 pr-2.5 pl-2 text-left transition-colors",
|
|
94
|
+
state,
|
|
95
|
+
className
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<span className={cn(markClass, "h-5 w-5")}>{mark}</span>
|
|
99
|
+
<span className="min-w-0 truncate text-xs font-medium">{label}</span>
|
|
100
|
+
{meta && <span className="shrink-0 font-mono text-[10px] leading-none text-muted-foreground/60">{meta}</span>}
|
|
101
|
+
{selected && <CheckIcon className="h-3.5 w-3.5 shrink-0 text-primary" aria-hidden />}
|
|
102
|
+
</Root>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<Root
|
|
108
|
+
{...common}
|
|
109
|
+
className={cn(
|
|
110
|
+
// Square-ISH, via a min-height rather than `aspect-square`: a strict
|
|
111
|
+
// square clipped "Node.js 20 (TypeScript)" the moment it wrapped onto a
|
|
112
|
+
// second line. Grid rows stretch their cells to the tallest anyway, so
|
|
113
|
+
// the row stays even without forcing the ratio.
|
|
114
|
+
"group relative flex min-h-[5.5rem] min-w-0 flex-col items-center justify-center gap-1.5 overflow-hidden rounded-lg border px-2 py-2.5 text-center transition-colors",
|
|
115
|
+
state,
|
|
116
|
+
className
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
{selected && <CheckIcon className="absolute top-1.5 right-1.5 h-3.5 w-3.5 text-primary" aria-hidden />}
|
|
120
|
+
|
|
121
|
+
<span className={cn(markClass, "h-8 w-8")}>{mark}</span>
|
|
122
|
+
|
|
123
|
+
{/* Two lines then ellipsis: "Node.js 20 (TypeScript)" needs both, and a
|
|
124
|
+
single truncated line would cut every entry of a same runtime to the
|
|
125
|
+
identical, useless "Node.js 20 (Type…". */}
|
|
126
|
+
<span className="line-clamp-2 w-full text-[11px] leading-tight font-medium break-words">{label}</span>
|
|
127
|
+
|
|
128
|
+
{meta && (
|
|
129
|
+
<span className="max-w-full truncate font-mono text-[9px] leading-none text-muted-foreground/70">{meta}</span>
|
|
130
|
+
)}
|
|
131
|
+
</Root>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { DownloadIcon, StarIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { CatalogCard, CatalogMark, type CatalogTone } from "./catalog-card"
|
|
8
|
+
import { PublisherBadge } from "./publisher-badge"
|
|
9
|
+
import { extensionId, formatCount, parseExtensionId } from "./registry"
|
|
10
|
+
import { resolveStackMark } from "./stack-mark"
|
|
11
|
+
import type { VscodeExtensionEntry } from "./types"
|
|
12
|
+
|
|
13
|
+
export interface ExtensionCardProps {
|
|
14
|
+
extension: VscodeExtensionEntry
|
|
15
|
+
selected?: boolean
|
|
16
|
+
onSelect?: () => void
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
compact?: boolean
|
|
19
|
+
/** An id typed by hand: no icon, no publisher, no counts to show. */
|
|
20
|
+
custom?: boolean
|
|
21
|
+
action?: ReactNode
|
|
22
|
+
tone?: CatalogTone
|
|
23
|
+
className?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A VS Code extension, as a card: its real Open VSX icon, its display name, the
|
|
28
|
+
* publisher (with the marketplace's verified check), version, install count and
|
|
29
|
+
* rating.
|
|
30
|
+
*
|
|
31
|
+
* Open VSX already returns every one of those on `/api/-/search` — the picker
|
|
32
|
+
* used to fetch them and throw them away, rendering `displayName` + `id`. Wiring
|
|
33
|
+
* them in is what turns a list of identifiers into a catalogue you can choose
|
|
34
|
+
* from.
|
|
35
|
+
*/
|
|
36
|
+
export function ExtensionCard({
|
|
37
|
+
extension,
|
|
38
|
+
selected = false,
|
|
39
|
+
onSelect,
|
|
40
|
+
disabled = false,
|
|
41
|
+
compact = false,
|
|
42
|
+
custom = false,
|
|
43
|
+
action,
|
|
44
|
+
tone = "flame",
|
|
45
|
+
className,
|
|
46
|
+
}: ExtensionCardProps) {
|
|
47
|
+
const id = extensionId(extension)
|
|
48
|
+
// A project only ever stores the id, so a card drawn from the database has to
|
|
49
|
+
// split it back apart to have anything to say at all.
|
|
50
|
+
const fromId = parseExtensionId(id)
|
|
51
|
+
const namespace = extension.namespace ?? fromId.namespace
|
|
52
|
+
const name = extension.name ?? fromId.name
|
|
53
|
+
const title = extension.displayName || name || id || "Extension"
|
|
54
|
+
const iconUrl = custom ? undefined : (extension.iconUrl ?? extension.files?.icon ?? undefined)
|
|
55
|
+
// Only ever a *fallback* mark: an extension's own icon is always better than
|
|
56
|
+
// a guess made from its id, so this is what fills the tile when Open VSX has
|
|
57
|
+
// no icon (or the card is drawn from a stored id alone).
|
|
58
|
+
const mark = custom ? undefined : resolveStackMark(name, extension.displayName)
|
|
59
|
+
const publisher =
|
|
60
|
+
extension.publisher ??
|
|
61
|
+
(namespace ? { name: namespace, login: namespace, verified: extension.verified } : undefined)
|
|
62
|
+
|
|
63
|
+
const downloads = formatCount(extension.downloadCount)
|
|
64
|
+
const rating = extension.averageRating != null ? Math.round(extension.averageRating * 10) / 10 : undefined
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<CatalogCard
|
|
68
|
+
className={className}
|
|
69
|
+
compact={compact}
|
|
70
|
+
custom={custom}
|
|
71
|
+
selected={selected}
|
|
72
|
+
onSelect={onSelect}
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
action={action}
|
|
75
|
+
tone={tone}
|
|
76
|
+
selectLabel={typeof title === "string" ? title : undefined}
|
|
77
|
+
mark={
|
|
78
|
+
<CatalogMark iconUrl={iconUrl} mark={mark} publisher={publisher} label={title} tone={tone} compact={compact} />
|
|
79
|
+
}
|
|
80
|
+
title={title}
|
|
81
|
+
titleMeta={
|
|
82
|
+
extension.version && !compact ? (
|
|
83
|
+
<span className="shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] leading-none text-muted-foreground">
|
|
84
|
+
v{extension.version}
|
|
85
|
+
</span>
|
|
86
|
+
) : undefined
|
|
87
|
+
}
|
|
88
|
+
meta={
|
|
89
|
+
!compact ? (
|
|
90
|
+
<>
|
|
91
|
+
{publisher && <PublisherBadge publisher={publisher} showAvatar={false} />}
|
|
92
|
+
{downloads && (
|
|
93
|
+
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground/70" title="Installs">
|
|
94
|
+
<DownloadIcon className="h-3 w-3 shrink-0" />
|
|
95
|
+
{downloads}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
{rating != null && rating > 0 && (
|
|
99
|
+
<span
|
|
100
|
+
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground/70"
|
|
101
|
+
title={extension.reviewCount ? `${extension.reviewCount} reviews` : "Average rating"}
|
|
102
|
+
>
|
|
103
|
+
<StarIcon className="h-3 w-3 shrink-0 fill-current" />
|
|
104
|
+
{rating.toFixed(1)}
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
</>
|
|
108
|
+
) : undefined
|
|
109
|
+
}
|
|
110
|
+
description={extension.description}
|
|
111
|
+
>
|
|
112
|
+
{id && (
|
|
113
|
+
<span
|
|
114
|
+
title={id}
|
|
115
|
+
className={cn(
|
|
116
|
+
"block max-w-full truncate font-mono text-[10px] leading-none text-muted-foreground/60",
|
|
117
|
+
compact ? "mt-0.5" : "mt-1.5"
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{id}
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
</CatalogCard>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Placeholder while the Open VSX search is in flight. The search used to show a
|
|
129
|
+
* spinner in the input and nothing else, so the dropdown appeared to pop out of
|
|
130
|
+
* nowhere; three of these keep the space and let you see it coming.
|
|
131
|
+
*/
|
|
132
|
+
export function ExtensionCardSkeleton({ className }: { className?: string }) {
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
aria-hidden
|
|
136
|
+
className={cn("flex items-start gap-3 rounded-lg border border-border bg-card px-3 py-2.5", className)}
|
|
137
|
+
>
|
|
138
|
+
<span className="h-9 w-9 shrink-0 animate-pulse rounded-md bg-muted" />
|
|
139
|
+
<div className="min-w-0 flex-1 space-y-1.5 py-0.5">
|
|
140
|
+
<span className="block h-3 w-1/3 animate-pulse rounded bg-muted" />
|
|
141
|
+
<span className="block h-2.5 w-1/4 animate-pulse rounded bg-muted/70" />
|
|
142
|
+
<span className="block h-2.5 w-3/4 animate-pulse rounded bg-muted/70" />
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|