@smallpen/core 0.1.0-alpha.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/package.json +18 -0
- package/src/canonical.mjs +64 -0
- package/src/capabilities.mjs +495 -0
- package/src/catalog.mjs +362 -0
- package/src/component-samples.mjs +84 -0
- package/src/components-domain.mjs +335 -0
- package/src/contexts.mjs +248 -0
- package/src/design-projection.mjs +657 -0
- package/src/design-read.mjs +825 -0
- package/src/design-system-authoring.mjs +102 -0
- package/src/design-system-canvas.mjs +862 -0
- package/src/design-system.mjs +437 -0
- package/src/design-validation.mjs +324 -0
- package/src/draft.mjs +784 -0
- package/src/effective-tokens.mjs +377 -0
- package/src/errors.mjs +12 -0
- package/src/index.mjs +112 -0
- package/src/initialization.mjs +310 -0
- package/src/package.mjs +5935 -0
- package/src/projection-values.mjs +138 -0
- package/src/requirements-domain.mjs +222 -0
- package/src/scenarios-domain.mjs +268 -0
- package/src/token-advice.mjs +272 -0
- package/src/token-import.mjs +607 -0
- package/src/tokens-domain.mjs +410 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { fail } from "./errors.mjs";
|
|
2
|
+
|
|
3
|
+
const PROJECT_KINDS = ["application", "motion", "custom"];
|
|
4
|
+
|
|
5
|
+
function isRecord(value) {
|
|
6
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function slug(value, separator = "_") {
|
|
10
|
+
const normalized = value
|
|
11
|
+
.normalize("NFKD")
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, separator)
|
|
14
|
+
.replace(new RegExp(`^${separator}+|${separator}+$`, "g"), "");
|
|
15
|
+
return normalized || "smallpen_project";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function nonEmptyString(value) {
|
|
19
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nonEmptyStrings(value) {
|
|
23
|
+
return (
|
|
24
|
+
Array.isArray(value) &&
|
|
25
|
+
value.length > 0 &&
|
|
26
|
+
value.every(nonEmptyString)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function contextAxes(value) {
|
|
31
|
+
return (
|
|
32
|
+
Array.isArray(value) &&
|
|
33
|
+
value.every(
|
|
34
|
+
(axis) =>
|
|
35
|
+
isRecord(axis) &&
|
|
36
|
+
nonEmptyString(axis.id) &&
|
|
37
|
+
axis.id.startsWith("axis_") &&
|
|
38
|
+
/^[a-zA-Z0-9_-]+$/.test(axis.id) &&
|
|
39
|
+
nonEmptyString(axis.name) &&
|
|
40
|
+
["accessibility", "custom", "density", "locale", "theme", "viewport"].includes(
|
|
41
|
+
axis.kind,
|
|
42
|
+
) &&
|
|
43
|
+
nonEmptyStrings(axis.values) &&
|
|
44
|
+
new Set(axis.values).size === axis.values.length &&
|
|
45
|
+
axis.values.includes(axis.defaultValue),
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const QUESTION_DEFINITIONS = [
|
|
51
|
+
{
|
|
52
|
+
id: "projectKind",
|
|
53
|
+
label: { en: "Project kind", "zh-TW": "專案類型" },
|
|
54
|
+
reason: "Selects the initial workflow and adapter scope.",
|
|
55
|
+
recommendation: "application",
|
|
56
|
+
schema: { enum: PROJECT_KINDS, type: "string" },
|
|
57
|
+
valid: (value) => PROJECT_KINDS.includes(value),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "projectName",
|
|
61
|
+
label: { en: "Project name", "zh-TW": "專案名稱" },
|
|
62
|
+
reason: "Derives stable package names and initial identifiers.",
|
|
63
|
+
recommendation: "My Product",
|
|
64
|
+
schema: { minLength: 1, type: "string" },
|
|
65
|
+
valid: nonEmptyString,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "purpose",
|
|
69
|
+
label: { en: "Purpose", "zh-TW": "目標" },
|
|
70
|
+
reason: "Records what the design must help people accomplish.",
|
|
71
|
+
schema: { minLength: 1, type: "string" },
|
|
72
|
+
valid: nonEmptyString,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "audience",
|
|
76
|
+
label: { en: "Audience", "zh-TW": "使用者/審閱者" },
|
|
77
|
+
reason: "Defines who will use or review the output.",
|
|
78
|
+
schema: { minLength: 1, type: "string" },
|
|
79
|
+
valid: nonEmptyString,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "firstOutput",
|
|
83
|
+
label: { en: "First output", "zh-TW": "首個交付物" },
|
|
84
|
+
reason: "Keeps initialization focused on one concrete first result.",
|
|
85
|
+
schema: { minLength: 1, type: "string" },
|
|
86
|
+
valid: nonEmptyString,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "foundationChoice",
|
|
90
|
+
label: { en: "Foundation", "zh-TW": "Foundation 選擇" },
|
|
91
|
+
reason: "Initialization currently creates a local Foundation beside the Product.",
|
|
92
|
+
recommendation: "create-new",
|
|
93
|
+
schema: { enum: ["create-new"], type: "string" },
|
|
94
|
+
valid: (value) => value === "create-new",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "platforms",
|
|
98
|
+
label: { en: "Platforms", "zh-TW": "平台/頁面稿目標" },
|
|
99
|
+
reason: "The first value becomes the Base Presentation and default view.",
|
|
100
|
+
recommendation: ["desktop"],
|
|
101
|
+
schema: { items: { minLength: 1, type: "string" }, minItems: 1, type: "array" },
|
|
102
|
+
valid: nonEmptyStrings,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "sourceInputs",
|
|
106
|
+
label: { en: "Source inputs", "zh-TW": "來源輸入" },
|
|
107
|
+
reason: "Persists references to existing research, assets, or designs.",
|
|
108
|
+
recommendation: ["none"],
|
|
109
|
+
schema: { items: { minLength: 1, type: "string" }, minItems: 1, type: "array" },
|
|
110
|
+
valid: nonEmptyStrings,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "contextAxes",
|
|
114
|
+
label: { en: "Context axes", "zh-TW": "Context 軸" },
|
|
115
|
+
reason: "Declares finite theme, density, locale, accessibility, or custom alternatives.",
|
|
116
|
+
recommendation: [
|
|
117
|
+
{
|
|
118
|
+
defaultValue: "light",
|
|
119
|
+
id: "axis_theme",
|
|
120
|
+
kind: "theme",
|
|
121
|
+
name: "Theme",
|
|
122
|
+
values: ["light", "dark"],
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
schema: {
|
|
126
|
+
items: {
|
|
127
|
+
required: ["defaultValue", "id", "kind", "name", "values"],
|
|
128
|
+
type: "object",
|
|
129
|
+
},
|
|
130
|
+
type: "array",
|
|
131
|
+
},
|
|
132
|
+
valid: contextAxes,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "initialTokens",
|
|
136
|
+
label: { en: "Initial Tokens", "zh-TW": "初始 Tokens" },
|
|
137
|
+
reason: "Creates a small shared Token vocabulary in Foundation.",
|
|
138
|
+
recommendation: ["color.brand", "spacing.md", "radius.md"],
|
|
139
|
+
schema: { items: { minLength: 1, type: "string" }, minItems: 1, type: "array" },
|
|
140
|
+
valid: nonEmptyStrings,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "initialComponents",
|
|
144
|
+
label: { en: "Initial components", "zh-TW": "初始元件" },
|
|
145
|
+
reason: "Creates explicit starter Component Sets in Foundation.",
|
|
146
|
+
recommendation: ["Button"],
|
|
147
|
+
schema: { items: { minLength: 1, type: "string" }, minItems: 1, type: "array" },
|
|
148
|
+
valid: nonEmptyStrings,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: "firstScreen",
|
|
152
|
+
label: { en: "First Screen", "zh-TW": "首個 Screen" },
|
|
153
|
+
reason: "Names the initial Product Screen.",
|
|
154
|
+
recommendation: "Home",
|
|
155
|
+
schema: { minLength: 1, type: "string" },
|
|
156
|
+
valid: nonEmptyString,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: "firstScenario",
|
|
160
|
+
label: { en: "First Scenario", "zh-TW": "首個 Scenario" },
|
|
161
|
+
reason: "Records one reproducible initial design state.",
|
|
162
|
+
recommendation: "Default",
|
|
163
|
+
schema: { minLength: 1, type: "string" },
|
|
164
|
+
valid: nonEmptyString,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "firstJourney",
|
|
168
|
+
label: { en: "First journey", "zh-TW": "首個使用流程" },
|
|
169
|
+
reason: "Seeds requirements and Flow navigation without executable scripts.",
|
|
170
|
+
recommendation: "Primary journey",
|
|
171
|
+
schema: { minLength: 1, type: "string" },
|
|
172
|
+
valid: nonEmptyString,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: "kindDetails",
|
|
176
|
+
label: { en: "Adapter scope", "zh-TW": "類型/轉接器細節" },
|
|
177
|
+
reason: "Captures runtime, motion, size, or delivery constraints for the selected kind.",
|
|
178
|
+
recommendation: ["responsive", "local-first"],
|
|
179
|
+
schema: { items: { minLength: 1, type: "string" }, minItems: 1, type: "array" },
|
|
180
|
+
valid: nonEmptyStrings,
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
function publicQuestion(question, statePath, locale) {
|
|
185
|
+
const language = locale?.toLowerCase().startsWith("zh") ? "zh-TW" : "en";
|
|
186
|
+
return {
|
|
187
|
+
choices: question.schema.enum ?? undefined,
|
|
188
|
+
continuation: {
|
|
189
|
+
args: [
|
|
190
|
+
"init",
|
|
191
|
+
"<workspace-directory>",
|
|
192
|
+
"--state",
|
|
193
|
+
statePath,
|
|
194
|
+
"--answer",
|
|
195
|
+
`${question.id}=<JSON>`,
|
|
196
|
+
"--json",
|
|
197
|
+
],
|
|
198
|
+
operation: "smallpen.init.answer",
|
|
199
|
+
},
|
|
200
|
+
id: question.id,
|
|
201
|
+
label: question.label[language],
|
|
202
|
+
labels: question.label,
|
|
203
|
+
reason: question.reason,
|
|
204
|
+
recommendation: structuredClone(question.recommendation),
|
|
205
|
+
required: true,
|
|
206
|
+
schema: structuredClone(question.schema),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function parseInitializationAnswers(value) {
|
|
211
|
+
if (!isRecord(value)) {
|
|
212
|
+
fail(
|
|
213
|
+
"invalid_initialization_answers",
|
|
214
|
+
"Initialization answers must contain an object",
|
|
215
|
+
{ path: "answers" },
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const known = new Set(QUESTION_DEFINITIONS.map(({ id }) => id));
|
|
219
|
+
for (const field of Object.keys(value)) {
|
|
220
|
+
if (!known.has(field)) {
|
|
221
|
+
fail(
|
|
222
|
+
"unknown_initialization_answer",
|
|
223
|
+
`Unknown Initialization answer: ${field}`,
|
|
224
|
+
{ field, path: `answers.${field}` },
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const result = {};
|
|
229
|
+
for (const question of QUESTION_DEFINITIONS) {
|
|
230
|
+
if (!Object.hasOwn(value, question.id)) continue;
|
|
231
|
+
if (!question.valid(value[question.id])) {
|
|
232
|
+
fail(
|
|
233
|
+
"invalid_initialization_answer",
|
|
234
|
+
`Initialization answer does not match ${question.id}`,
|
|
235
|
+
{ path: `answers.${question.id}`, questionId: question.id, schema: question.schema },
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
result[question.id] = structuredClone(value[question.id]);
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function proposal(answers) {
|
|
244
|
+
const project = slug(answers.projectName);
|
|
245
|
+
const firstScreen = slug(answers.firstScreen);
|
|
246
|
+
const platform = slug(answers.platforms[0]);
|
|
247
|
+
const packageIds = {
|
|
248
|
+
foundation: `pkg_${project}_foundation`,
|
|
249
|
+
product: `pkg_${project}_product`,
|
|
250
|
+
};
|
|
251
|
+
return {
|
|
252
|
+
assumptions: [
|
|
253
|
+
"The first platform is the Base Presentation and default Design View.",
|
|
254
|
+
"Foundation owns shared Contexts, Tokens, and starter Component Sets.",
|
|
255
|
+
"Product owns Screens, Scenarios, requirements, Flows, and local overrides.",
|
|
256
|
+
],
|
|
257
|
+
brief: structuredClone(answers),
|
|
258
|
+
firstDesign: {
|
|
259
|
+
flowId: `flow_${slug(answers.firstJourney)}`,
|
|
260
|
+
name: answers.firstScreen,
|
|
261
|
+
presentationId: `pres_${firstScreen}_${platform}`,
|
|
262
|
+
requirementId: `req_${slug(answers.firstJourney)}`,
|
|
263
|
+
scenarioId: `scn_${firstScreen}_${slug(answers.firstScenario)}`,
|
|
264
|
+
screenId: `scr_${firstScreen}`,
|
|
265
|
+
},
|
|
266
|
+
packages: {
|
|
267
|
+
foundation: {
|
|
268
|
+
directoryName: `${project.replaceAll("_", "-")}-foundation.smallpen`,
|
|
269
|
+
name: `${answers.projectName} Foundation`,
|
|
270
|
+
packageId: packageIds.foundation,
|
|
271
|
+
role: "foundation",
|
|
272
|
+
},
|
|
273
|
+
product: {
|
|
274
|
+
directoryName: `${project.replaceAll("_", "-")}.smallpen`,
|
|
275
|
+
name: answers.projectName,
|
|
276
|
+
packageId: packageIds.product,
|
|
277
|
+
role: "product",
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
proposalVersion: 1,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function createInitializationState(answersValue, options = {}) {
|
|
285
|
+
const answers = parseInitializationAnswers(answersValue);
|
|
286
|
+
const missing = QUESTION_DEFINITIONS.filter(
|
|
287
|
+
(question) => !Object.hasOwn(answers, question.id),
|
|
288
|
+
);
|
|
289
|
+
if (missing.length > 0) {
|
|
290
|
+
const statePath = options.statePath ?? "smallpen-init.json";
|
|
291
|
+
return {
|
|
292
|
+
answers,
|
|
293
|
+
nextQuestion: publicQuestion(
|
|
294
|
+
missing[0],
|
|
295
|
+
statePath,
|
|
296
|
+
options.locale ?? "zh-TW",
|
|
297
|
+
),
|
|
298
|
+
pendingQuestionIds: missing.map(({ id }) => id),
|
|
299
|
+
status: "needs_input",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
proposal: proposal(answers),
|
|
304
|
+
status: "proposal",
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export const INITIALIZATION_QUESTION_IDS = Object.freeze(
|
|
309
|
+
QUESTION_DEFINITIONS.map(({ id }) => id),
|
|
310
|
+
);
|