actual-css 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.
Files changed (89) hide show
  1. package/CHANGELOG.md +99 -1
  2. package/README.md +6 -2
  3. package/components.json +14 -3
  4. package/dist/actual.full.css +483 -128
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/enhancement-contract.md +16 -7
  8. package/docs/design-notes/platform-alignment.md +8 -4
  9. package/docs/design-notes/rich-text.md +61 -0
  10. package/docs/design-notes/steps.md +2 -2
  11. package/docs/navigation.json +4 -1
  12. package/docs/pages/components/aura.md +12 -13
  13. package/docs/pages/components/badge.md +10 -0
  14. package/docs/pages/components/bleed.md +5 -4
  15. package/docs/pages/components/button.md +1 -1
  16. package/docs/pages/components/chat.md +5 -0
  17. package/docs/pages/components/close.md +9 -3
  18. package/docs/pages/components/dialog.md +145 -13
  19. package/docs/pages/components/meter.md +2 -0
  20. package/docs/pages/components/pagination.md +15 -15
  21. package/docs/pages/components/shimmer.md +57 -0
  22. package/docs/pages/components/skeleton.md +2 -1
  23. package/docs/pages/components/spinner.md +11 -6
  24. package/docs/pages/components/table.md +13 -0
  25. package/docs/pages/enhancements/flyout.md +2 -0
  26. package/docs/pages/enhancements/tabs.md +4 -0
  27. package/docs/pages/examples/overview.md +1 -0
  28. package/docs/pages/forms/choice-cards.md +77 -4
  29. package/docs/pages/forms/combobox.md +16 -1
  30. package/docs/pages/forms/overview.md +4 -1
  31. package/docs/pages/forms/range.md +110 -5
  32. package/docs/pages/forms/rich-text.md +110 -0
  33. package/docs/pages/forms/validation.md +5 -1
  34. package/docs/pages/foundations/theming.md +49 -3
  35. package/docs/pages/foundations/tokens.md +15 -5
  36. package/docs/pages/guides/bootstrap.md +2 -1
  37. package/docs/pages/guides/design-tools.md +36 -0
  38. package/docs/pages/guides/introduction.md +1 -1
  39. package/docs/pages/guides/modular-import.md +1 -1
  40. package/docs/pages/guides/progressive-enhancement.md +13 -14
  41. package/docs/pages/guides/tailwind.md +4 -2
  42. package/llms.txt +4 -3
  43. package/package.json +6 -1
  44. package/reserved-classes.json +1 -0
  45. package/skills/actual-css/references/enhancements.md +35 -0
  46. package/src/cli/actual-css.js +49 -6
  47. package/src/css/components/avatar.css +1 -1
  48. package/src/css/components/bleed.css +5 -5
  49. package/src/css/components/button.css +14 -1
  50. package/src/css/components/close.css +17 -6
  51. package/src/css/components/flyout.css +4 -1
  52. package/src/css/components/meter.css +6 -3
  53. package/src/css/components/modal.css +123 -7
  54. package/src/css/components/pagination.css +3 -2
  55. package/src/css/components/rating.css +1 -1
  56. package/src/css/components/status-bar.css +2 -2
  57. package/src/css/components/steps.css +3 -3
  58. package/src/css/components/tab.css +3 -3
  59. package/src/css/components/table.css +6 -0
  60. package/src/css/components/tooltip.css +1 -1
  61. package/src/css/core/focus.css +14 -4
  62. package/src/css/core/theme.css +8 -0
  63. package/src/css/core/tokens.css +8 -4
  64. package/src/css/core/variants.css +2 -1
  65. package/src/css/effects/aura.css +51 -33
  66. package/src/css/effects/index.css +1 -0
  67. package/src/css/effects/shimmer.css +57 -0
  68. package/src/css/forms/choice-card.css +71 -23
  69. package/src/css/forms/control.css +2 -2
  70. package/src/css/forms/native.css +5 -1
  71. package/src/css/forms/otp.css +9 -1
  72. package/src/css/forms/range.css +64 -21
  73. package/src/js/command.js +61 -32
  74. package/src/js/dialog.js +8 -23
  75. package/src/js/dismiss.js +1 -2
  76. package/src/js/enhance.js +84 -123
  77. package/src/js/full.js +1 -0
  78. package/src/js/password.js +1 -8
  79. package/src/js/range.js +151 -0
  80. package/src/js/scrollspy.js +4 -5
  81. package/src/js/status.js +2 -10
  82. package/src/js/tooltip.js +11 -1
  83. package/src/js/validation.js +4 -10
  84. package/src/tooling/design-components.js +286 -0
  85. package/src/tooling/design-export.js +69 -0
  86. package/src/tooling/design-tokens.js +466 -0
  87. package/src/tooling/penpot-plugin/manifest.json +7 -0
  88. package/src/tooling/penpot-plugin/plugin.js +272 -0
  89. package/src/tooling/penpot-plugin/ui.html +58 -0
@@ -0,0 +1,466 @@
1
+ /*
2
+ * Design-tool token export: resolves a theme's tokens in headless Chrome and
3
+ * writes them for Figma (DTCG files, one per mode) and Penpot (one token file
4
+ * with its sets and themes).
5
+ *
6
+ * Values are read from the browser, never from the CSS text: tokens are built
7
+ * with light-dark(), color-mix() and var() chains that only the browser
8
+ * evaluates faithfully. The CSS source only tells which declarations are pure
9
+ * aliases (`--a: var(--b)`), so the design tool keeps the relationship instead
10
+ * of two unrelated equal values. Names stay the CSS names without `--`.
11
+ * Bun only: Bun.WebView drives the browser.
12
+ */
13
+ import { readFile } from "node:fs/promises";
14
+ import { stripComments } from "./css-bundle.js";
15
+
16
+ const CORE_TOKEN_FILES = ["../css/core/tokens.css", "../css/core/theme.css"];
17
+
18
+ /* Painted colors, one value per mode. The knobs that produce derived colors
19
+ (mix ratios, backdrop color and opacity) are not exported: the design tool
20
+ receives what is painted, not how it was computed. */
21
+ export const THEME_COLORS = [
22
+ "primary",
23
+ "primary-fg",
24
+ "secondary",
25
+ "secondary-fg",
26
+ "success",
27
+ "success-fg",
28
+ "warning",
29
+ "warning-fg",
30
+ "danger",
31
+ "danger-fg",
32
+ "neutral",
33
+ "neutral-fg",
34
+ "surface",
35
+ "surface-raised",
36
+ "surface-subtle",
37
+ "surface-solid",
38
+ "surface-solid-fg",
39
+ "surface-opaque",
40
+ "text",
41
+ "text-muted",
42
+ "text-subtle",
43
+ "heading",
44
+ "border",
45
+ "focus",
46
+ "hover-overlay",
47
+ "shadow-color",
48
+ "selection-bg",
49
+ "selection-fg",
50
+ "state-selected",
51
+ "state-selected-fg",
52
+ "state-disabled",
53
+ "indicator-ring",
54
+ "backdrop-fill",
55
+ ];
56
+
57
+ /* Mode-independent tokens by role. The role tells a design tool where a value
58
+ applies (Penpot token types); every role but font-weight is a length. */
59
+ export const FOUNDATIONS = {
60
+ spacing: [
61
+ "space-10",
62
+ "space-20",
63
+ "space-30",
64
+ "space-40",
65
+ "space-50",
66
+ "space-60",
67
+ "gap",
68
+ "control-pad-x",
69
+ ],
70
+ radius: ["radius-sm", "radius", "radius-lg", "radius-full"],
71
+ "border-width": ["border-width", "focus-ring-width"],
72
+ "font-size": [
73
+ "font-size-xs",
74
+ "font-size-sm",
75
+ "font-size-md",
76
+ "font-size-lg",
77
+ "control-font-size",
78
+ ],
79
+ size: ["control-size-sm", "control-size-md", "control-size-lg", "control-size", "bar-height"],
80
+ offset: ["focus-outline-offset"],
81
+ "font-weight": [
82
+ "font-weight-normal",
83
+ "font-weight-medium",
84
+ "font-weight-semibold",
85
+ "font-weight-bold",
86
+ ],
87
+ };
88
+
89
+ /* Every custom property declared by the core token files is exported or
90
+ listed here (tests/design-tokens.test.js), so a new token cannot silently
91
+ stay out of the design tools. */
92
+ export const NOT_EXPORTED = {
93
+ "font stack; a design tool font token holds one family": ["font-sans", "font-mono"],
94
+ "no design tool equivalent": [
95
+ "font-width",
96
+ "font-width-dense",
97
+ "line-height",
98
+ "line-height-tight",
99
+ "line-height-relaxed",
100
+ "shadow",
101
+ "shadow-popout",
102
+ "duration",
103
+ "duration-fast",
104
+ "duration-slow",
105
+ "duration-spin",
106
+ "duration-shimmer",
107
+ "ease-enter",
108
+ "ease-exit",
109
+ "viewport-block",
110
+ "viewport-inline",
111
+ "z-sticky",
112
+ "z-menu",
113
+ "z-status",
114
+ "icon-chevron",
115
+ "icon-check",
116
+ "icon-close",
117
+ "icon-plus",
118
+ ],
119
+ "input of a derived value": [
120
+ "backdrop-color",
121
+ "backdrop-opacity",
122
+ "soft-bg-mix",
123
+ "soft-border-mix",
124
+ "soft-hover-alpha",
125
+ "soft-fg-mix",
126
+ "focus-outline",
127
+ "focus-outline-color",
128
+ ],
129
+ "soft recipe, exported with the components": [
130
+ "primary-soft-fg",
131
+ "secondary-soft-fg",
132
+ "success-soft-fg",
133
+ "warning-soft-fg",
134
+ "danger-soft-fg",
135
+ "neutral-soft-fg",
136
+ ],
137
+ "internal or plumbing": [
138
+ "density-space",
139
+ "disabled-opacity",
140
+ "indicator-offset",
141
+ "choice-control-size",
142
+ "hgroup-gap",
143
+ "tone-fg",
144
+ ],
145
+ };
146
+
147
+ const WEIGHTS = FOUNDATIONS["font-weight"];
148
+ const LENGTHS = Object.entries(FOUNDATIONS).flatMap(([role, names]) =>
149
+ role === "font-weight" ? [] : names,
150
+ );
151
+ const ROLE_OF = new Map(
152
+ Object.entries(FOUNDATIONS).flatMap(([role, names]) => names.map((n) => [n, role])),
153
+ );
154
+
155
+ const DECLARATION_RE = /(?<![-\w])--([a-z0-9-]+)\s*:\s*([^;{}]*);/gi;
156
+ const ALIAS_RE = /^var\(\s*--([a-z0-9-]+)\s*\)$/i;
157
+ const THEME_NAME_RE = /\[data-theme="([^"]+)"\]/g;
158
+
159
+ export function declaredTokens(css) {
160
+ return new Set([...stripComments(css).matchAll(DECLARATION_RE)].map((m) => m[1]));
161
+ }
162
+
163
+ /* Every `--a: var(--b)` declaration, per name. Only candidates: the source
164
+ cannot tell which one applies (a theme may alias a token the core declares
165
+ literally, a forced-colors block may replace an alias), so the resolved
166
+ values decide. */
167
+ export function aliasCandidates(...sources) {
168
+ const aliases = new Map();
169
+ for (const css of sources) {
170
+ for (const [, name, value] of stripComments(css).matchAll(DECLARATION_RE)) {
171
+ const target = value.trim().match(ALIAS_RE)?.[1];
172
+ if (target) aliases.set(name, [...(aliases.get(name) ?? []), target]);
173
+ }
174
+ }
175
+ return aliases;
176
+ }
177
+
178
+ /* The one data-theme value a theme file declares, light/dark excluded. */
179
+ export function themeNameOf(css) {
180
+ const names = new Set(
181
+ [...css.matchAll(THEME_NAME_RE)].map((m) => m[1]).filter((n) => n !== "light" && n !== "dark"),
182
+ );
183
+ if (names.size !== 1) {
184
+ throw new Error(
185
+ `Expected one [data-theme="…"] name in the theme, found ${names.size === 0 ? "none" : [...names].join(", ")}; pass the name explicitly.`,
186
+ );
187
+ }
188
+ return [...names][0];
189
+ }
190
+
191
+ export async function coreTokenCss() {
192
+ const files = CORE_TOKEN_FILES.map((path) => readFile(new URL(path, import.meta.url), "utf8"));
193
+ return (await Promise.all(files)).join("\n");
194
+ }
195
+
196
+ /*
197
+ * Runs in the page. Each island is one mode: `color-scheme` on the island is
198
+ * what light-dark() resolves against. Colors go through color-mix(in srgb) so
199
+ * every color syntax computes to the same `color(srgb r g b / a)` form.
200
+ */
201
+ function readIslands({ css, islands, colors, lengths, weights }) {
202
+ const style = document.createElement("style");
203
+ style.textContent = css;
204
+ document.head.append(style);
205
+
206
+ const result = [];
207
+ for (const { attribute, scheme } of islands) {
208
+ const island = document.createElement("div");
209
+ if (attribute) island.dataset.theme = attribute;
210
+ if (scheme) island.style.colorScheme = scheme;
211
+ const probe = document.createElement("div");
212
+ probe.style.position = "absolute";
213
+ island.append(probe);
214
+ document.body.append(island);
215
+
216
+ const computed = getComputedStyle(probe);
217
+ const values = { colors: {}, lengths: {}, weights: {}, missing: [] };
218
+ const read = (name) => {
219
+ const raw = computed.getPropertyValue(`--${name}`).trim();
220
+ if (!raw) values.missing.push(name);
221
+ return raw;
222
+ };
223
+
224
+ for (const name of colors) {
225
+ if (!read(name)) continue;
226
+ probe.style.color = `color-mix(in srgb, var(--${name}), var(--${name}))`;
227
+ values.colors[name] = computed.color;
228
+ }
229
+ for (const name of lengths) {
230
+ const raw = read(name);
231
+ if (!raw) continue;
232
+ // column-gap reports the computed length; width would report the used
233
+ // one, snapped to 1/64px by layout (0.65rem came out as 10.39).
234
+ probe.style.columnGap = `var(--${name})`;
235
+ values.lengths[name] = { px: Number.parseFloat(computed.columnGap), css: raw };
236
+ }
237
+ for (const name of weights) {
238
+ const raw = read(name);
239
+ if (raw) values.weights[name] = Number(raw);
240
+ }
241
+ values.scheme = attribute ? getComputedStyle(island).colorScheme : null;
242
+ result.push(values);
243
+ island.remove();
244
+ }
245
+ return result;
246
+ }
247
+
248
+ /* Runs `fn(input)` in a blank headless page. `fn` is serialized, so it must be
249
+ self-contained: no closure over module scope. */
250
+ export async function inPage(fn, input) {
251
+ await using view = new Bun.WebView({ backend: "chrome" });
252
+ await view.navigate("about:blank");
253
+ // `return await`: a bare return lets `await using` close the view first.
254
+ return await view.evaluate(`(${fn.toString()})(${JSON.stringify(input)})`);
255
+ }
256
+
257
+ const evaluateIslands = (input) => inPage(readIslands, input);
258
+
259
+ /*
260
+ * Resolves one theme. `css` is the full stylesheet (framework + theme, imports
261
+ * inlined). Without `theme`, the default theme is read through its light and
262
+ * dark boundaries. A named theme exports the schemes it declares: `light dark`
263
+ * gives two modes, anything else one. `islands` is returned so component
264
+ * measurements read the same boundaries.
265
+ */
266
+ export async function resolveTokens({ css, theme, aliasSources = [] }) {
267
+ let islands = [
268
+ { mode: "light", attribute: "light" },
269
+ { mode: "dark", attribute: "dark" },
270
+ ];
271
+ if (theme) {
272
+ const [declared] = await evaluateIslands({
273
+ css,
274
+ islands: [{ attribute: theme }],
275
+ colors: [],
276
+ lengths: [],
277
+ weights: [],
278
+ });
279
+ const schemes = declared.scheme.split(/\s+/).filter((s) => s === "light" || s === "dark");
280
+ islands = (schemes.length > 0 ? schemes : ["light"]).map((mode) => ({
281
+ mode,
282
+ attribute: theme,
283
+ scheme: mode,
284
+ }));
285
+ }
286
+
287
+ const read = await evaluateIslands({
288
+ css,
289
+ islands,
290
+ colors: THEME_COLORS,
291
+ lengths: LENGTHS,
292
+ weights: WEIGHTS,
293
+ });
294
+ const missing = [...new Set(read.flatMap((values) => values.missing))];
295
+ if (missing.length > 0) {
296
+ throw new Error(`Tokens without a value: ${missing.map((n) => `--${n}`).join(", ")}`);
297
+ }
298
+
299
+ const modes = Object.fromEntries(
300
+ islands.map(({ mode }, i) => [
301
+ mode,
302
+ Object.fromEntries(Object.entries(read[i].colors).map(([n, c]) => [n, parseSrgb(c)])),
303
+ ]),
304
+ );
305
+ // Geometry does not follow the color scheme; the first mode is the reference.
306
+ const [{ lengths, weights }] = read;
307
+ return { islands, modes, lengths, weights, aliases: aliasCandidates(...aliasSources) };
308
+ }
309
+
310
+ export function parseSrgb(value) {
311
+ const match = value.match(/^color\(srgb ([^ ]+) ([^ ]+) ([^ )/]+)(?: \/ ([^ )]+))?\)$/);
312
+ if (!match) throw new Error(`Unexpected computed color: ${value}`);
313
+ const clamp = (n) => Math.min(1, Math.max(0, Number(n)));
314
+ return {
315
+ components: match.slice(1, 4).map((n) => round(clamp(n))),
316
+ alpha: match[4] === undefined ? 1 : round(clamp(match[4])),
317
+ };
318
+ }
319
+
320
+ const round = (n) => Math.round(n * 10000) / 10000;
321
+
322
+ export function hexOf({ components, alpha }) {
323
+ const byte = (n) =>
324
+ Math.round(n * 255)
325
+ .toString(16)
326
+ .padStart(2, "0");
327
+ return `#${components.map(byte).join("")}${alpha < 1 ? byte(alpha) : ""}`;
328
+ }
329
+
330
+ const colorsEqual = (a, b) =>
331
+ a.alpha === b.alpha && a.components.every((n, i) => n === b.components[i]);
332
+
333
+ /* A candidate becomes an alias only when its target is exported in the same
334
+ group and holds the same value in every mode; otherwise the theme overrode
335
+ the token literally. */
336
+ function aliasOf(name, valueSets, aliases, same) {
337
+ return aliases.get(name)?.find((t) => valueSets.every((v) => t in v && same(v[t], v[name])));
338
+ }
339
+
340
+ /*
341
+ * The target-neutral model both writers read: per mode, each color is
342
+ * `{ alias }` or `{ color, hex }`; each foundation carries its role and is
343
+ * `{ alias }`, `{ px, css }` or `{ weight }`.
344
+ */
345
+ export function linkTokens({ modes, lengths, weights, aliases }) {
346
+ const modeSets = Object.values(modes);
347
+ const linkedModes = Object.fromEntries(
348
+ Object.entries(modes).map(([mode, colors]) => [
349
+ mode,
350
+ Object.fromEntries(
351
+ Object.entries(colors).map(([name, color]) => {
352
+ const alias = aliasOf(name, modeSets, aliases, colorsEqual);
353
+ return [name, alias ? { alias } : { color, hex: hexOf(color) }];
354
+ }),
355
+ ),
356
+ ]),
357
+ );
358
+
359
+ const foundations = {};
360
+ for (const [name, length] of Object.entries(lengths)) {
361
+ const alias = aliasOf(name, [lengths], aliases, (a, b) => a.px === b.px);
362
+ foundations[name] = { role: ROLE_OF.get(name), ...(alias ? { alias } : length) };
363
+ }
364
+ for (const [name, weight] of Object.entries(weights)) {
365
+ foundations[name] = { role: "font-weight", weight };
366
+ }
367
+ return { modes: linkedModes, foundations };
368
+ }
369
+
370
+ const EXTENSION = "actual-css";
371
+
372
+ /*
373
+ * Figma: DTCG documents keyed by file name, `foundations` plus one per color
374
+ * mode. Figma turns each imported file into a collection whose single mode is
375
+ * named after the file, hence `light` / `dark`. Recipe colors (see
376
+ * design-components.js) join the mode files so they switch with the mode.
377
+ */
378
+ export function toFigma({ modes, foundations }, recipes = {}) {
379
+ const files = {};
380
+ for (const [mode, colors] of Object.entries(modes)) {
381
+ files[mode] = Object.fromEntries(
382
+ Object.entries({ ...colors, ...recipes[mode] }).map(([name, token]) => [
383
+ name,
384
+ {
385
+ $type: "color",
386
+ $value: token.alias
387
+ ? `{${token.alias}}`
388
+ : { colorSpace: "srgb", ...token.color, hex: token.hex },
389
+ },
390
+ ]),
391
+ );
392
+ }
393
+
394
+ files.foundations = Object.fromEntries(
395
+ Object.entries(foundations).map(([name, token]) => {
396
+ // `number`, not DTCG `fontWeight`: Figma's import drops fontWeight
397
+ // tokens, and a number variable is what binds to a text layer's weight.
398
+ if (token.role === "font-weight") return [name, { $type: "number", $value: token.weight }];
399
+ const $value = token.alias ? `{${token.alias}}` : { value: token.px, unit: "px" };
400
+ const doc = { $type: "dimension", $value };
401
+ if (token.css) doc.$extensions = { [EXTENSION]: { css: token.css } };
402
+ return [name, doc];
403
+ }),
404
+ );
405
+ return files;
406
+ }
407
+
408
+ /* Penpot token types per role: the type decides which property a token can
409
+ be applied to in the Penpot UI. */
410
+ const PENPOT_TYPES = {
411
+ spacing: "spacing",
412
+ radius: "borderRadius",
413
+ "border-width": "borderWidth",
414
+ "font-size": "fontSizes",
415
+ size: "sizing",
416
+ offset: "dimension",
417
+ "font-weight": "fontWeights",
418
+ };
419
+
420
+ const PENPOT_THEME_GROUP = "Mode";
421
+
422
+ /*
423
+ * Penpot: one token file holding every set plus `$themes` and `$metadata`.
424
+ * Sets are `foundations` and `mode/<scheme>`, recipe colors included (their
425
+ * names keep them apart, and they always switch with the mode); each theme of
426
+ * the Mode group enables foundations and one scheme, so switching theme only
427
+ * swaps colors. Values are plain strings (hex, `16px`): Penpot does not read
428
+ * the DTCG object forms.
429
+ */
430
+ export function toPenpot({ modes, foundations }, recipes = {}) {
431
+ const doc = {
432
+ foundations: Object.fromEntries(
433
+ Object.entries(foundations).map(([name, token]) => {
434
+ const $type = PENPOT_TYPES[token.role];
435
+ if (token.alias) return [name, { $type, $value: `{${token.alias}}` }];
436
+ return [name, { $type, $value: token.weight ?? `${token.px}px` }];
437
+ }),
438
+ ),
439
+ };
440
+
441
+ const colorSet = (colors) =>
442
+ Object.fromEntries(
443
+ Object.entries(colors).map(([name, token]) => [
444
+ name,
445
+ { $type: "color", $value: token.alias ? `{${token.alias}}` : token.hex },
446
+ ]),
447
+ );
448
+ const schemes = Object.keys(modes);
449
+ for (const mode of schemes) {
450
+ doc[`mode/${mode}`] = colorSet({ ...modes[mode], ...recipes[mode] });
451
+ }
452
+
453
+ const title = (mode) => mode[0].toUpperCase() + mode.slice(1);
454
+ doc.$themes = schemes.map((mode) => ({
455
+ id: `mode-${mode}`,
456
+ name: title(mode),
457
+ group: PENPOT_THEME_GROUP,
458
+ selectedTokenSets: { foundations: "enabled", [`mode/${mode}`]: "enabled" },
459
+ }));
460
+ doc.$metadata = {
461
+ tokenSetOrder: ["foundations", ...schemes.map((mode) => `mode/${mode}`)],
462
+ activeThemes: [`${PENPOT_THEME_GROUP}/${title(schemes[0])}`],
463
+ activeSets: ["foundations", `mode/${schemes[0]}`],
464
+ };
465
+ return doc;
466
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "Actual CSS components",
3
+ "description": "Builds Actual CSS components from a measured spec and binds them to the imported tokens.",
4
+ "version": 2,
5
+ "code": "plugin.js",
6
+ "permissions": ["content:read", "content:write", "library:read", "library:write"]
7
+ }