@scattered-light/loomiere 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +3 -1
  2. package/bin/loomiere.mjs +3 -1
  3. package/build/build-tokens.mjs +180 -35
  4. package/exporters/design-system.mjs +7 -0
  5. package/exporters/figma.mjs +545 -478
  6. package/exporters/index.mjs +4 -0
  7. package/figma/README.md +24 -8
  8. package/figma/library/README.md +92 -0
  9. package/figma/library/check/known-differences.md +20 -0
  10. package/figma/library/check/mojave.json +246 -0
  11. package/figma/library/kit/base.js +89 -0
  12. package/figma/library/kit/components.js +95 -0
  13. package/figma/library/kit/docs.js +37 -0
  14. package/figma/library/kit/fingerprint.js +143 -0
  15. package/figma/library/pages/02-palettes.js +40 -0
  16. package/figma/library/pages/03-color.js +126 -0
  17. package/figma/library/pages/04-type.js +56 -0
  18. package/figma/library/pages/05-scale.js +58 -0
  19. package/figma/library/pages/06-size.js +111 -0
  20. package/figma/library/pages/07-elevation-focus-motion.js +108 -0
  21. package/figma/library/pages/08-themes.js +36 -0
  22. package/figma/library/pages/09-fields/01-input.js +55 -0
  23. package/figma/library/pages/09-fields/02-checks.js +82 -0
  24. package/figma/library/pages/09-fields/03-field.js +32 -0
  25. package/figma/library/pages/09-fields/04-more-fields.js +144 -0
  26. package/figma/library/pages/09-fields/05-labels.js +14 -0
  27. package/figma/library/pages/09-fields/06-doc.js +75 -0
  28. package/figma/library/pages/10-buttons/01-buttons.js +97 -0
  29. package/figma/library/pages/10-buttons/02-doc.js +55 -0
  30. package/figma/library/pages/11-popups/01-popups.js +166 -0
  31. package/figma/library/pages/11-popups/02-more-popups.js +90 -0
  32. package/figma/library/pages/11-popups/03-labels.js +14 -0
  33. package/figma/library/pages/11-popups/04-doc.js +89 -0
  34. package/figma/library/pages/12-overlays/01-overlays.js +135 -0
  35. package/figma/library/pages/12-overlays/02-labels.js +10 -0
  36. package/figma/library/pages/12-overlays/03-doc.js +80 -0
  37. package/figma/library/pages/13-disclosure/01-disclosure.js +205 -0
  38. package/figma/library/pages/13-disclosure/02-collapsible.js +39 -0
  39. package/figma/library/pages/13-disclosure/03-labels.js +16 -0
  40. package/figma/library/pages/13-disclosure/04-doc.js +91 -0
  41. package/figma/library/pages/14-display/01-display.js +165 -0
  42. package/figma/library/pages/14-display/02-more-display.js +55 -0
  43. package/figma/library/pages/14-display/03-labels.js +14 -0
  44. package/figma/library/pages/14-display/04-doc.js +96 -0
  45. package/figma/library/pages/15-cover.js +87 -0
  46. package/figma/library/run-order.json +21 -0
  47. package/figma/library/tools/compare.mjs +11 -0
  48. package/figma/library/tools/diff-snippet.js +15 -0
  49. package/figma/library/tools/kit-script.mjs +21 -0
  50. package/figma/library/tools/run.mjs +27 -0
  51. package/figma/library/tools/runs.mjs +34 -0
  52. package/generator/brand.schema.json +83 -0
  53. package/generator/generate.mjs +26 -6
  54. package/generator/looks.mjs +62 -0
  55. package/lib/contrast.mjs +24 -6
  56. package/lib/figma.mjs +58 -23
  57. package/package.json +2 -1
  58. package/tokens/README.md +26 -0
@@ -0,0 +1,97 @@
1
+ // Buttons: Button (with Label and Icon only content) and Toggle.
2
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
3
+ var C = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'compHelpers'))(figma);
4
+ var V = C.V, COL = C.COL, v = C.v, fill = C.fill, stroke = C.stroke, bind = C.bind, radius = C.radius, add = C.add, T = C.T, mode = C.mode, page = C.page;
5
+ var icon = C.icon, comp = C.comp, textProp = C.textProp, boolProp = C.boolProp, place = C.place, removeOld = C.removeOld;
6
+ var ES = {}; (await figma.getLocalEffectStylesAsync()).forEach(function (s) { ES[s.name] = s; });
7
+ await page('Buttons');
8
+ var PLUS = 'M 8 3 L 8 13 M 3 8 L 13 8', ARROW = 'M 3 8 L 13 8 M 9 4 L 13 8 L 9 12', MARK = 'M 4 2 L 12 2 L 12 14 L 8 11 L 4 14 Z';
9
+ var TIER = { xs: 'minimize', sm: 'condense', md: 'base', lg: 'magnify' }, UP = { xs: 'condense', sm: 'base', md: 'magnify', lg: 'maximize' };
10
+ function num(k) { var x = v(k); var m = COL[k.split(':')[0]].modes[0].modeId, val = x.valuesByMode[m]; while (val && val.type === 'VARIABLE_ALIAS') { var t = Object.values(V).find(function (y) { return y.id === val.id; }); val = t.valuesByMode[Object.keys(t.valuesByMode)[0]]; } return val; }
11
+ function sfx(size) { return size === 'md' ? '' : '-' + size; }
12
+ function frameSet(set, w, h) {
13
+ set.resize(w, h);
14
+ fill(set, 'Theme:surface/canvas'); set.strokes = [{ type: 'SOLID', color: { r: 0.592, g: 0.278, b: 1 } }]; set.dashPattern = [6, 4]; set.strokeWeight = 1; set.cornerRadius = 8;
15
+ set.clipsContent = false;
16
+ }
17
+ // Each variant sits centered in a 64px row.
18
+ function rowY(row, c) { return 32 + row * 64 + Math.round((64 - c.height) / 2); }
19
+
20
+ // Button: rows are emphasis and state; columns are sizes, with a label and then icon only.
21
+ var EMS = ['Bold', 'Standard', 'Subtle', 'Quiet'], STATES = ['Default', 'Hover', 'Pressed', 'Focus', 'Disabled'], SIZES = ['xs', 'sm', 'md', 'lg'];
22
+ var CONTENT = ['Label', 'Icon only'], COLX = { Label: [32, 162, 292, 432], 'Icon only': [592, 662, 732, 812] };
23
+ removeOld('Button');
24
+ var comps = [], at = [];
25
+ for (var n = 0; n < CONTENT.length; n++) for (var e = 0; e < EMS.length; e++) for (var s = 0; s < STATES.length; s++) for (var z = 0; z < SIZES.length; z++) {
26
+ var ct = CONTENT[n], em = EMS[e], st = STATES[s], size = SIZES[z], only = ct === 'Icon only';
27
+ var c = comp('Emphasis=' + em + ', Size=' + size + ', State=' + st + ', Content=' + ct);
28
+ c.primaryAxisAlignItems = 'CENTER'; c.counterAxisAlignItems = 'CENTER';
29
+ mode(c, 'Emphasis', em);
30
+ fill(c, st === 'Hover' ? 'Emphasis:emphasis/bg-hover' : st === 'Pressed' ? 'Emphasis:emphasis/bg-active' : 'Emphasis:emphasis/bg');
31
+ stroke(c, st === 'Hover' ? 'Emphasis:emphasis/border-hover' : 'Emphasis:emphasis/border', 'Size:stroke/default'); c.strokeAlign = 'INSIDE';
32
+ radius(c, 'Size:button/radius');
33
+ if (!only) bind(c, ['paddingLeft', 'paddingRight'], 'Size:control/padding-inline' + sfx(size));
34
+ bind(c, 'itemSpacing', 'Size:gap/control' + (size === 'xs' || size === 'sm' ? '-' + size : ''));
35
+ var isz = num('Size:icon-size/' + UP[size]);
36
+ if (only) add(c, [icon('Icon', PLUS, isz, 'Emphasis:emphasis/fg')]);
37
+ else {
38
+ var i1 = icon('Icon start', PLUS, isz, 'Emphasis:emphasis/fg');
39
+ var lab = await T('Save trip', 'trigger-' + TIER[size], 'Emphasis:emphasis/fg'); lab.name = 'Label';
40
+ var i2 = icon('Icon end', ARROW, isz, 'Emphasis:emphasis/fg');
41
+ add(c, [i1, lab, i2]); i1.visible = false; i2.visible = false;
42
+ }
43
+ c.counterAxisSizingMode = 'FIXED';
44
+ // Icon only is a circle as wide as it is tall.
45
+ if (only) { c.primaryAxisSizingMode = 'FIXED'; c.paddingLeft = c.paddingRight = 0; c.resize(40, 40); bind(c, ['width', 'height'], 'Size:control/height-' + size); }
46
+ else { c.resize(c.width, 40); bind(c, 'height', 'Size:control/height-' + size); }
47
+ if (st === 'Focus') await c.setEffectStyleIdAsync(ES['focus/ring'].id);
48
+ if (st === 'Disabled') bind(c, 'opacity', 'Size:button/opacity-disabled');
49
+ c.clipsContent = true;
50
+ comps.push(c); at.push([COLX[ct][z], e * STATES.length + s]);
51
+ }
52
+ var bset = figma.combineAsVariants(comps, figma.currentPage); bset.name = 'Button';
53
+ bset.layoutMode = 'NONE';
54
+ comps.forEach(function (c, i) { c.x = at[i][0]; c.y = rowY(at[i][1], c); });
55
+ frameSet(bset, 924, 64 + EMS.length * STATES.length * 64);
56
+ textProp(bset, 'Label', 'Save trip', 'Label'); boolProp(bset, 'Icon start', false, 'Icon start'); boolProp(bset, 'Icon end', false, 'Icon end');
57
+ bset.description = 'An action. Emphasis sets how loud it is: Bold for the one main action in a view, Standard for the others, Subtle and Quiet for actions that step back. Content: Label, or Icon only (a circle; give it an accessible name). For small frequent icon actions such as calendar arrows and row actions, use Quiet, Icon only, xs, with the palette set to neutral. Set the Status mode on an instance (Error for a delete) to recolor any emphasis.';
58
+ place(bset);
59
+
60
+ // Toggle: a button that stays pressed. Rows are pressed and state; columns are sizes.
61
+ var TST = ['Default', 'Hover', 'Focus', 'Disabled'], TSZ = ['sm', 'md', 'lg'], TRAD = { sm: 'Size:radius/item', md: 'Size:radius/chrome', lg: 'Size:radius/surface' }, PR = ['Off', 'On'];
62
+ removeOld('Toggle');
63
+ var tcomps = [], tat = [];
64
+ for (var p = 0; p < PR.length; p++) for (var s = 0; s < TST.length; s++) for (var z = 0; z < TSZ.length; z++) {
65
+ var on = PR[p] === 'On', st = TST[s], size = TSZ[z];
66
+ var c = comp('Pressed=' + PR[p] + ', Size=' + size + ', State=' + st);
67
+ c.primaryAxisAlignItems = 'CENTER'; c.counterAxisAlignItems = 'CENTER';
68
+ mode(c, 'Emphasis', 'Standard');
69
+ if (on) { fill(c, 'Emphasis:emphasis/bg-active'); stroke(c, st === 'Hover' ? 'Emphasis:emphasis/border-hover' : 'Emphasis:emphasis/border', 'Size:stroke/default'); }
70
+ else { fill(c, st === 'Hover' ? 'Theme:surface/raised' : 'Theme:surface/canvas'); stroke(c, st === 'Hover' ? 'Theme:border/strong' : 'Theme:border/default', 'Size:stroke/default'); }
71
+ c.strokeAlign = 'INSIDE';
72
+ radius(c, TRAD[size]);
73
+ bind(c, ['paddingLeft', 'paddingRight'], 'Size:control/padding-inline' + sfx(size));
74
+ bind(c, 'itemSpacing', 'Size:gap/control' + (size === 'sm' ? '-sm' : ''));
75
+ var fg = on ? 'Emphasis:emphasis/fg' : 'Theme:text/primary';
76
+ var ic = icon('Icon', MARK, num('Size:icon-size/' + UP[size]), fg);
77
+ var lab = await T('Bookmark', 'trigger-' + TIER[size], fg); lab.name = 'Label';
78
+ add(c, [ic, lab]);
79
+ c.counterAxisSizingMode = 'FIXED'; c.resize(c.width, 40); bind(c, 'height', 'Size:control/height-' + size);
80
+ if (st === 'Focus') await c.setEffectStyleIdAsync(ES['focus/ring'].id);
81
+ if (st === 'Disabled') bind(c, 'opacity', 'Size:toggle/opacity-disabled');
82
+ c.clipsContent = true;
83
+ tcomps.push(c); tat.push([32 + z * 190, p * TST.length + s]);
84
+ }
85
+ var tset = figma.combineAsVariants(tcomps, figma.currentPage); tset.name = 'Toggle';
86
+ tset.layoutMode = 'NONE';
87
+ tcomps.forEach(function (c, i) { c.x = tat[i][0]; c.y = rowY(tat[i][1], c); });
88
+ frameSet(tset, 64 + TSZ.length * 190, 64 + PR.length * TST.length * 64);
89
+ textProp(tset, 'Label', 'Bookmark', 'Label'); boolProp(tset, 'Icon', true, 'Icon');
90
+ tset.description = 'A button that stays pressed: bold text, a view mode, a filter. Same heights, padding and text as a Button of the same size. On takes the pressed look of the Standard emphasis; set the Emphasis mode on an instance for a louder or quieter on state. For a setting that applies at once, use a Switch.';
91
+ place(tset);
92
+
93
+ // Headers and labels. Button's columns name the size and say which are icon only.
94
+ await C.labelSets({ Button: 'An action. Each row is an emphasis and state; columns are sizes, with a label and icon only. Set Status on an instance to recolor it.', Toggle: 'A button that stays pressed. Rows are pressed and state, columns are sizes.' });
95
+ var bw = figma.currentPage.findOne(function (n) { return n.type === 'FRAME' && n.name === 'Button' && n.getSharedPluginData('loomiere', 'wrap') === '1'; });
96
+ bw.findOne(function (n) { return n.name === 'Column labels'; }).children.forEach(function (t) { t.characters = t.characters.replace(' · LABEL', '').replace('ICON ONLY', 'ICON'); t.name = t.characters; });
97
+ return { button: comps.length, toggle: tcomps.length };
@@ -0,0 +1,55 @@
1
+ // Buttons doc frame. Run after 01-buttons.js.
2
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
3
+ var K = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'docHelpers'))(figma);
4
+ await K.page('Buttons');
5
+ var L = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'docKit'))(figma);
6
+ var V = L.V, COL = L.COL, v = L.v, paint = L.paint, fill = L.fill, stroke = L.stroke, bind = L.bind, radius = L.radius, pad = L.pad, AL = L.AL, add = L.add, T = L.T, mode = L.mode, docFrame = L.docFrame, section = L.section, hrow = L.hrow, divider = L.divider;
7
+ var inst = L.inst, notes = L.notes, captioned = L.captioned, usage = L.usage;
8
+ function stage(parent, name) { var s = L.stage(parent, name); s.counterAxisAlignItems = 'CENTER'; return s; }
9
+ var doc = await docFrame('Buttons', 'COMPONENTS', 'Buttons', 'Things a person presses. Button runs an action, with a label or as an icon alone; Toggle stays pressed. They share the control heights, padding and trigger text with fields, so a button always lines up with the field beside it. Components sit to the right.');
10
+ doc.x = 0; doc.y = 400;
11
+ function B(em, size, st, props, content) { return inst('Button', 'Emphasis=' + em + ', Size=' + size + ', State=' + st + ', Content=' + (content || 'Label'), props); }
12
+
13
+ var pick = await section(doc, 'Choosing a button', 'Start from how important the action is, then how it behaves.');
14
+ var picks = [['The one main action in a view', 'Button · Bold', 'Save, Book, Continue. One per view, so the eye knows where to go.'], ['Other actions beside it', 'Button · Standard', 'Cancel, Back, Edit. Clearly a button, but quieter than the main one.'], ['Actions that should step back', 'Button · Subtle', 'Secondary actions in a toolbar or a card footer.'], ['Low-key actions in dense places', 'Button · Quiet', 'Inline actions in tables and lists. No fill until hover.'], ['A small icon action: arrows, row actions', 'Button · Quiet, icon only', 'Quiet at xs, so it never competes with the content.'], ['An option that stays on', 'Toggle', 'Bold text, a view mode, a filter. It holds its pressed state.'], ['A setting that applies at once', 'Switch', 'On the Fields page. No Save button needed.']];
15
+ var pt = AL('VERTICAL', { name: 'Picker' }); add(pick, [pt]); pt.layoutSizingHorizontal = 'FILL';
16
+ for (var i = 0; i < picks.length; i++) { var r = hrow(pt); divider(r); add(r, [await T(picks[i][0], 'body-base', 'Theme:text/primary', 380), await T(picks[i][1], 'subtitle-base', 'Theme:text/accent', 200), await T(picks[i][2], 'body-condense', 'Theme:text/secondary', 520)]); }
17
+
18
+ var es = await section(doc, 'Emphasis and status', 'Emphasis is how loud a button is; Status is what it means. They are separate, so a quiet delete and a bold delete are both possible. Set the Emphasis variant on the instance and the Status mode on the instance or any frame around it.');
19
+ var gridF = AL('VERTICAL', { name: 'Status × emphasis', itemSpacing: 0 }); add(es, [gridF]); gridF.layoutSizingHorizontal = 'FILL';
20
+ var EMS = ['Bold', 'Standard', 'Subtle', 'Quiet'], STS = [['Default', 'Most actions'], ['Info', 'Learn more, tours'], ['Success', 'Confirm, approve'], ['Warning', 'Proceed with care'], ['Error', 'Delete, remove']];
21
+ var h = hrow(gridF); h.paddingTop = 0; divider(h);
22
+ add(h, [await T('STATUS', 'eyebrow-condense', 'Theme:text/tertiary', 200)]);
23
+ for (var e = 0; e < EMS.length; e++) add(h, [await T(EMS[e].toUpperCase(), 'eyebrow-condense', 'Theme:text/tertiary', 160)]);
24
+ for (var s = 0; s < STS.length; s++) {
25
+ var r = hrow(gridF); divider(r); r.paddingTop = r.paddingBottom = 16; mode(r, 'Status', STS[s][0]);
26
+ var lab = AL('VERTICAL', { name: 'Label', itemSpacing: 2 }); add(lab, [await T(STS[s][0], 'subtitle-base'), await T(STS[s][1], 'body-condense', 'Theme:text/secondary')]); lab.resize(200, lab.height); lab.counterAxisSizingMode = 'FIXED';
27
+ add(r, [lab]);
28
+ for (var e = 0; e < EMS.length; e++) { var cell = AL('HORIZONTAL', { name: EMS[e] }); cell.resize(160, 44); cell.primaryAxisSizingMode = 'FIXED'; add(cell, [B(EMS[e], 'md', 'Default', { Label: STS[s][0] === 'Error' ? 'Delete' : STS[s][0] === 'Success' ? 'Approve' : 'Save trip' })]); add(r, [cell]); }
29
+ }
30
+ await usage(es, ['One Bold button per view: the action most people came to do.', 'Error status for anything that destroys data, at whatever emphasis fits the page.', 'Standard for Cancel next to a Bold action, so the pair reads as choice and default.'], ['Two Bold buttons side by side. Neither reads as the main one.', 'Status for decoration. A green button should mean approve or confirm.', 'Quiet buttons on their own with no context; they can look like text.']);
31
+
32
+ var zs = await section(doc, 'Sizes and icons', 'Four sizes, the same heights as fields: xs with minimize text up to lg with magnify. Icons are one tier above the text so they fill the line. Turn on Icon start or Icon end on an instance.');
33
+ var st1 = stage(zs);
34
+ var SZ = ['xs', 'sm', 'md', 'lg'];
35
+ for (var z = 0; z < SZ.length; z++) add(st1, [await captioned(B('Bold', SZ[z], 'Default'), SZ[z])]);
36
+ var st2 = stage(zs, 'Icons');
37
+ add(st2, [await captioned(B('Standard', 'md', 'Default', { 'Icon start': true, Label: 'New trip' }), 'Icon start'), await captioned(B('Standard', 'md', 'Default', { 'Icon end': true, Label: 'Continue' }), 'Icon end'), await captioned(B('Bold', 'lg', 'Default', { 'Icon start': true, Label: 'New trip' }), 'lg with icon')]);
38
+ var stS = stage(zs, 'States');
39
+ var STATES = ['Default', 'Hover', 'Pressed', 'Focus', 'Disabled'];
40
+ for (var s = 0; s < STATES.length; s++) add(stS, [await captioned(B('Bold', 'md', STATES[s]), STATES[s])]);
41
+ await notes(zs, [['Height', 'control/height-xs … lg', 'The same heights as fields, so a button lines up with the input beside it.'], ['Padding', 'control/padding-inline-*', 'Per size, from the base unit.'], ['Gap', 'gap/control-*', 'Between icon and label.'], ['Text', 'trigger-minimize … trigger-magnify', 'Whole text styles, one per size.'], ['Icon', 'icon-size/condense … maximize', 'One tier above the text.'], ['Colors', 'emphasis/bg, fg, border (+ -hover, bg-active)', 'From the Emphasis and Status collections; nothing is set per button.'], ['Shape', 'button/radius (pill)', 'Buttons are pills so they never read as fields or cards.'], ['Focus', 'focus/ring + interactive/tint-hover halo', 'See Elevation, focus and motion.'], ['Disabled', 'button/opacity-disabled (0.5)', 'Fades as a whole, keeping its shape so the layout does not jump.']]);
42
+
43
+ var io = await section(doc, 'Icon only', 'Set Content to Icon only and the button becomes a circle the height of its size. Every emphasis and size works. Give it an accessible name in code (aria-label). For small, frequent actions such as calendar arrows and row actions, use Quiet at xs. In this brand Quiet already uses the text color; in code, palette="neutral" keeps it gray for a brand whose quiet level is colored.');
44
+ var st3 = stage(io);
45
+ for (var e = 0; e < EMS.length; e++) add(st3, [await captioned(B(EMS[e], 'md', 'Default', null, 'Icon only'), EMS[e])]);
46
+ for (var z of ['xs', 'sm', 'lg']) add(st3, [await captioned(B('Quiet', z, 'Default', null, 'Icon only'), 'Quiet ' + z)]);
47
+ add(st3, [await captioned(B('Quiet', 'xs', 'Hover', null, 'Icon only'), 'Quiet xs, hover')]);
48
+
49
+ var ts = await section(doc, 'Toggle', 'A button that stays pressed. Same heights, padding and text as a Button of the same size. Off is a plain outlined button; on takes the pressed look of the Standard emphasis, so the brand\'s emphasis ladder decides how loud on is.');
50
+ var st4 = stage(ts);
51
+ for (var p of ['Off', 'On']) for (var s of ['Default', 'Hover']) add(st4, [await captioned(inst('Toggle', 'Pressed=' + p + ', Size=md, State=' + s), p + ', ' + s.toLowerCase())]);
52
+ add(st4, [await captioned(inst('Toggle', 'Pressed=On, Size=md, State=Focus'), 'Focus'), await captioned(inst('Toggle', 'Pressed=Off, Size=md, State=Disabled'), 'Disabled')]);
53
+ await usage(ts, ['Formatting: bold, italic, align.', 'A view option or filter that stays on until turned off.', 'Groups of toggles where one or several can be on.'], ['A setting that saves at once. That is a Switch.', 'An action that happens once. That is a Button.']);
54
+ await notes(ts, [['Off', 'surface/canvas · border/default', 'Hover surface/raised and border/strong.'], ['On', 'emphasis/bg-active · fg · border', 'Standard emphasis by default; set Emphasis on the instance for louder or quieter.'], ['Radius', 'radius/item · chrome · surface', 'sm, md, lg. Toggles are not pills, so a row of them reads as a set of options.'], ['Size', 'control/height-sm … lg', 'Same as Button.'], ['Disabled', 'toggle/opacity-disabled', 'Fades as a whole.']]);
55
+ return { id: doc.id, h: Math.round(doc.height) };
@@ -0,0 +1,166 @@
1
+ // Popups: Menu item, List item, Popup part, Menu, List popup, Popover, Tooltip. Rebuilds the page's Components column.
2
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
3
+ var C = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'compHelpers'))(figma);
4
+ var V = C.V, COL = C.COL, TS = C.TS, v = C.v, paint = C.paint, fill = C.fill, stroke = C.stroke, bind = C.bind, radius = C.radius, pad = C.pad, AL = C.AL, add = C.add, T = C.T, mode = C.mode, page = C.page;
5
+ var shadow = C.shadow, focusRing = C.focusRing, icon = C.icon, comp = C.comp, textProp = C.textProp, boolProp = C.boolProp, place = C.place, removeOld = C.removeOld, arrange = C.arrange;
6
+ var here = function (n) { return figma.currentPage.findOne(function (x) { return x.type === 'COMPONENT_SET' && x.name === n; }); };
7
+ var pp = await page('Popups');
8
+ figma.currentPage.children.filter(function (n) { return n.name === 'Components'; }).forEach(function (n) { n.remove(); });
9
+ var ES = {}; (await figma.getLocalEffectStylesAsync()).forEach(function (s) { ES[s.name] = s; });
10
+ var CHECK = 'M 3 8.5 L 6.5 12 L 13 4.5', CHEV = 'M 6 4 L 10 8 L 6 12', XP = 'M 4 4 L 12 12 M 12 4 L 4 12';
11
+ function has(k) { return !!V[k]; }
12
+ function grid(set, cols, colW, rowH) {
13
+ set.layoutMode = 'NONE';
14
+ set.children.forEach(function (k, i) { var r = Math.floor(i / cols); k.x = 32 + (i % cols) * colW; k.y = 32 + r * rowH + Math.round((rowH - k.height) / 2); });
15
+ set.resize(64 + cols * colW - 24, 64 + Math.ceil(set.children.length / cols) * rowH);
16
+ fill(set, 'Theme:surface/canvas'); set.strokes = [{ type: 'SOLID', color: { r: 0.592, g: 0.278, b: 1 } }]; set.dashPattern = [6, 4]; set.strokeWeight = 1; set.cornerRadius = 8; set.clipsContent = false;
17
+ }
18
+ function row(set, gap) { // lay variants out in one row, each at its own size
19
+ set.layoutMode = 'NONE'; var x = 32, h = 0;
20
+ set.children.forEach(function (k) { k.x = x; k.y = 48; x += k.width + (gap || 64); h = Math.max(h, k.height); });
21
+ set.resize(x - (gap || 64) + 32, h + 96);
22
+ fill(set, 'Theme:surface/canvas'); set.strokes = [{ type: 'SOLID', color: { r: 0.592, g: 0.278, b: 1 } }]; set.dashPattern = [6, 4]; set.strokeWeight = 1; set.cornerRadius = 8; set.clipsContent = false;
23
+ }
24
+ function dot(colorKey) {
25
+ var f = figma.createFrame(); f.name = 'Dot'; f.fills = []; f.resize(16, 16); f.clipsContent = false;
26
+ var e = figma.createEllipse(); e.resize(6, 6); e.x = 5; e.y = 5; e.fills = [paint(colorKey)]; f.appendChild(e); return f;
27
+ }
28
+ function slot(child) { // item-indicator: a square of item/indicator-size around the mark
29
+ var s = AL('HORIZONTAL', { name: 'Indicator', primaryAxisAlignItems: 'CENTER', counterAxisAlignItems: 'CENTER' });
30
+ s.resize(16, 16); s.primaryAxisSizingMode = 'FIXED'; s.counterAxisSizingMode = 'FIXED'; bind(s, ['width', 'height'], 'Size:item/indicator-size');
31
+ add(s, [child]); return s;
32
+ }
33
+ async function item(name, label, state, opts) {
34
+ opts = opts || {};
35
+ var c = comp(name); c.counterAxisAlignItems = 'CENTER';
36
+ pad(c, opts.dense ? 'Size:item/padding-block-dense' : 'Size:item/padding-block', 'Size:item/padding-inline');
37
+ bind(c, 'itemSpacing', 'Size:item/gap'); radius(c, 'Size:item/radius');
38
+ var fg = state === 'Disabled' ? 'Theme:text/disabled' : state === 'Highlighted' ? 'Theme:text/on-tint' : state === 'Selected' ? 'Theme:text/on-tint-strong' : 'Theme:text/primary';
39
+ if (state === 'Highlighted') fill(c, 'Theme:interactive/tint-hover');
40
+ if (state === 'Selected') fill(c, 'Theme:interactive/tint-active');
41
+ if (opts.mark === 'check') add(c, [slot(icon('Check', CHECK, 16, 'Theme:interactive/default', 2))]);
42
+ if (opts.mark === 'dot') add(c, [slot(dot('Theme:interactive/default'))]);
43
+ var t = await T(label, state === 'Selected' ? 'selected-base' : 'body-base', fg); t.name = 'Label'; add(c, [t]); t.layoutSizingHorizontal = 'FILL';
44
+ if (opts.sub) add(c, [icon('Submenu', CHEV, 16, 'Theme:text/tertiary')]);
45
+ c.primaryAxisSizingMode = 'FIXED'; c.resize(240, c.height);
46
+ if (state === 'Disabled') { c.opacity = 0.5; bind(c, 'opacity', 'Size:item/opacity-disabled'); }
47
+ return c;
48
+ }
49
+
50
+ // Menu item: Kind × State.
51
+ var KINDS = [['Action', {}, 'Rename'], ['Checkbox', { mark: 'check' }, 'Show grid'], ['Radio', { mark: 'dot' }, 'Sort by name'], ['Submenu', { sub: true }, 'Move to']];
52
+ var MST = ['Default', 'Highlighted', 'Disabled'], mc = [];
53
+ for (var k = 0; k < KINDS.length; k++) for (var s = 0; s < MST.length; s++) {
54
+ var o = Object.assign({ dense: true }, KINDS[k][1]);
55
+ mc.push(await item('Kind=' + KINDS[k][0] + ', State=' + MST[s], KINDS[k][2], MST[s], o));
56
+ }
57
+ var mset = figma.combineAsVariants(mc, figma.currentPage); mset.name = 'Menu item'; grid(mset, 3, 270, 56);
58
+ textProp(mset, 'Label', 'Rename', 'Label');
59
+ mset.description = 'A row in a Menu. Action runs something; Checkbox and Radio show a mark when on (off, they look like Action); Submenu opens another menu. Highlighted is the pointer or keyboard position. Menu rows use item/padding-block-dense, a little tighter than list options.';
60
+ place(mset);
61
+
62
+ // List item: an option in a Select or Combobox list.
63
+ var LST = ['Default', 'Highlighted', 'Selected', 'Disabled'], lc = [];
64
+ for (var s = 0; s < LST.length; s++) lc.push(await item('State=' + LST[s], 'Mojave Desert', LST[s], { mark: LST[s] === 'Selected' ? 'check' : null }));
65
+ var lset = figma.combineAsVariants(lc, figma.currentPage); lset.name = 'List item'; grid(lset, 4, 270, 64);
66
+ textProp(lset, 'Label', 'Mojave Desert', 'Label');
67
+ lset.description = 'An option in a Select or Combobox list. Selected is the chosen option: a stronger tint, the selected text style and a check. Highlighted follows the pointer or keyboard.';
68
+ place(lset);
69
+
70
+ // Popup parts: group label, separator, empty message.
71
+ var parts = [];
72
+ var gl = comp('Part=Group label'); bind(gl, 'paddingTop', 'Size:popup/group-label-padding-top'); bind(gl, 'paddingBottom', 'Size:popup/group-label-padding-bottom'); bind(gl, ['paddingLeft', 'paddingRight'], 'Size:item/padding-inline');
73
+ var glt = await T('SORT', 'eyebrow-condense', 'Theme:text/secondary'); glt.name = 'Label'; add(gl, [glt]); gl.primaryAxisSizingMode = 'FIXED'; gl.resize(240, gl.height); parts.push(gl);
74
+ var sp = comp('Part=Separator', 'VERTICAL'); bind(sp, ['paddingTop', 'paddingBottom'], 'Size:popup/separator-spacing');
75
+ var ln = figma.createRectangle(); ln.name = 'Line'; ln.resize(240, 1); fill(ln, 'Theme:border/subtle'); bind(ln, 'height', 'Size:stroke/default'); add(sp, [ln]); ln.layoutSizingHorizontal = 'FILL';
76
+ sp.counterAxisSizingMode = 'FIXED'; sp.resize(240, sp.height); parts.push(sp);
77
+ var em = comp('Part=Empty'); em.primaryAxisAlignItems = 'CENTER'; bind(em, ['paddingTop', 'paddingBottom'], 'Size:popup/empty-padding-block'); bind(em, ['paddingLeft', 'paddingRight'], 'Size:item/padding-inline');
78
+ var emt = await T('No regions match', 'body-condense', 'Theme:text/tertiary'); emt.name = 'Message'; add(em, [emt]); em.primaryAxisSizingMode = 'FIXED'; em.resize(240, em.height); parts.push(em);
79
+ var pset = figma.combineAsVariants(parts, figma.currentPage); pset.name = 'Popup part'; row(pset, 48);
80
+ textProp(pset, 'Label', 'SORT', 'Label'); textProp(pset, 'Message', 'No regions match', 'Message');
81
+ pset.description = 'The pieces between rows: a group label (eyebrow text), a separator, and the message a list shows when nothing matches.';
82
+ place(pset);
83
+
84
+ function inst(set, name) { return set.children.find(function (c) { return c.name === name; }).createInstance(); }
85
+ function setLabel(i, text) { var p = {}; Object.keys(i.componentProperties).forEach(function (k) { if (/^(Label|Message)#/.test(k)) p[k] = text; }); i.setProperties(p); return i; }
86
+ async function surface(c, fieldRadius) {
87
+ fill(c, 'Theme:surface/canvas'); stroke(c, 'Theme:border/default', 'Size:popup/border-width'); c.strokeAlign = 'INSIDE';
88
+ radius(c, fieldRadius ? 'Size:popup/radius-field' : 'Size:popup/radius');
89
+ await c.setEffectStyleIdAsync(ES['elevation/lg'].id); c.clipsContent = true;
90
+ }
91
+ function fillAll(c) { c.children.forEach(function (k) { try { k.layoutSizingHorizontal = 'FILL'; } catch (e) {} }); }
92
+
93
+ // Menu: a popup of menu items.
94
+ var menus = [];
95
+ var m1 = comp('Content=Actions', 'VERTICAL'); bind(m1, ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'], 'Size:popup/padding'); await surface(m1);
96
+ add(m1, [setLabel(inst(mset, 'Kind=Action, State=Default'), 'Rename'), setLabel(inst(mset, 'Kind=Action, State=Highlighted'), 'Duplicate'), setLabel(inst(mset, 'Kind=Submenu, State=Default'), 'Move to'), inst(pset, 'Part=Separator'), setLabel(inst(mset, 'Kind=Action, State=Disabled'), 'Archive'), setLabel(inst(mset, 'Kind=Action, State=Default'), 'Delete')]);
97
+ m1.counterAxisSizingMode = 'FIXED'; m1.resize(240, m1.height); bind(m1, 'width', 'Size:popup/min-width'); fillAll(m1); menus.push(m1);
98
+ var m2 = comp('Content=Options', 'VERTICAL'); bind(m2, ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'], 'Size:popup/padding'); await surface(m2);
99
+ add(m2, [setLabel(inst(pset, 'Part=Group label'), 'VIEW'), setLabel(inst(mset, 'Kind=Checkbox, State=Default'), 'Show grid'), setLabel(inst(mset, 'Kind=Action, State=Highlighted'), 'Show rulers'), inst(pset, 'Part=Separator'), setLabel(inst(pset, 'Part=Group label'), 'SORT'), setLabel(inst(mset, 'Kind=Radio, State=Default'), 'Name'), setLabel(inst(mset, 'Kind=Action, State=Default'), 'Date')]);
100
+ m2.counterAxisSizingMode = 'FIXED'; m2.resize(240, m2.height); bind(m2, 'width', 'Size:popup/min-width'); fillAll(m2); menus.push(m2);
101
+ var menuSet = figma.combineAsVariants(menus, figma.currentPage); menuSet.name = 'Menu'; row(menuSet, 64);
102
+ menuSet.description = 'A list of actions or options that opens from a button. Rows are Menu item instances; swap their Kind and State. Popup surface: surface/canvas, border/default, popup/radius and the elevation/lg effect style.';
103
+ place(menuSet);
104
+
105
+ // List popup: the list a Select or Combobox opens, the field's width and corners.
106
+ var lists = [];
107
+ var REG = ['Great Basin', 'Mojave Desert', 'Sonoran Desert', 'Chihuahuan Desert'];
108
+ var l1 = comp('Content=Options', 'VERTICAL'); bind(l1, ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'], 'Size:popup/padding'); await surface(l1, true);
109
+ add(l1, [setLabel(inst(lset, 'State=Default'), REG[0]), setLabel(inst(lset, 'State=Selected'), REG[1]), setLabel(inst(lset, 'State=Highlighted'), REG[2]), setLabel(inst(lset, 'State=Disabled'), REG[3])]);
110
+ l1.counterAxisSizingMode = 'FIXED'; l1.resize(280, l1.height); fillAll(l1); lists.push(l1);
111
+ var l2 = comp('Content=Grouped', 'VERTICAL'); bind(l2, ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'], 'Size:popup/padding'); await surface(l2, true);
112
+ add(l2, [setLabel(inst(pset, 'Part=Group label'), 'HOT DESERTS'), setLabel(inst(lset, 'State=Selected'), REG[1]), setLabel(inst(lset, 'State=Default'), REG[2]), inst(pset, 'Part=Separator'), setLabel(inst(pset, 'Part=Group label'), 'COLD DESERTS'), setLabel(inst(lset, 'State=Default'), REG[0])]);
113
+ l2.counterAxisSizingMode = 'FIXED'; l2.resize(280, l2.height); fillAll(l2); lists.push(l2);
114
+ var l3 = comp('Content=Empty', 'VERTICAL'); bind(l3, ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'], 'Size:popup/padding'); await surface(l3, true);
115
+ add(l3, [inst(pset, 'Part=Empty')]); l3.counterAxisSizingMode = 'FIXED'; l3.resize(280, l3.height); fillAll(l3); lists.push(l3);
116
+ var listSet = figma.combineAsVariants(lists, figma.currentPage); listSet.name = 'List popup'; row(listSet, 48);
117
+ listSet.description = 'The list a Select or Combobox opens below its field: as wide as the field, with the field\'s corners (popup/radius-field) so the two read as one control. Grouped adds group labels and a separator; Empty is a Combobox with no matches. A long list scrolls after popup/list-max-height.';
118
+ place(listSet);
119
+
120
+ // Arrow: a triangle in the popup's fill, with its edge stroked where the popup has a border.
121
+ function arrow(fillKey, strokeKey, w, h) {
122
+ var a = figma.createFrame(); a.name = 'Arrow'; a.fills = []; a.resize(w, h); a.clipsContent = false;
123
+ var f = figma.createVector(); f.vectorPaths = [{ windingRule: 'NONZERO', data: 'M 0 ' + (h + 1) + ' L ' + w / 2 + ' 0 L ' + w + ' ' + (h + 1) + ' Z' }]; f.fills = [paint(fillKey)]; f.strokes = []; a.appendChild(f); f.x = 0; f.y = 0;
124
+ if (strokeKey) { var s = figma.createVector(); s.vectorPaths = [{ windingRule: 'NONE', data: 'M 0 ' + h + ' L ' + w / 2 + ' 0 L ' + w + ' ' + h }]; s.fills = []; s.strokes = [paint(strokeKey)]; s.strokeWeight = 1; a.appendChild(s); s.x = 0; s.y = 0; }
125
+ return a;
126
+ }
127
+ async function closeX() {
128
+ var b = AL('HORIZONTAL', { name: 'Close', primaryAxisAlignItems: 'CENTER', counterAxisAlignItems: 'CENTER' });
129
+ b.resize(24, 24); b.primaryAxisSizingMode = 'FIXED'; b.counterAxisSizingMode = 'FIXED'; bind(b, ['width', 'height'], 'Size:icon-button/size'); radius(b, 'Size:icon-button/radius');
130
+ add(b, [icon('X', XP, 16, 'Theme:text/tertiary')]); return b;
131
+ }
132
+ // Popover.
133
+ var pops = [];
134
+ for (var a of ['Top', 'None']) {
135
+ var c = comp('Arrow=' + a, 'VERTICAL'); c.counterAxisAlignItems = 'CENTER'; c.itemSpacing = -1;
136
+ var surf = AL('VERTICAL', { name: 'Popup' }); bind(surf, ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'], 'Size:popup/padding-panel'); bind(surf, 'itemSpacing', 'Scale:space/1-5');
137
+ await surface(surf);
138
+ var tt = await T('Trip reminders', 'title-magnify', 'Theme:text/primary'); tt.name = 'Title';
139
+ var dd = await T('We\'ll email you a week before you leave, and again the day before.', 'body-base', 'Theme:text/secondary'); dd.name = 'Description';
140
+ add(surf, [tt, dd]); surf.resize(300, surf.height); surf.counterAxisSizingMode = 'FIXED'; tt.layoutSizingHorizontal = 'FILL'; dd.layoutSizingHorizontal = 'FILL'; tt.textAutoResize = 'HEIGHT'; dd.textAutoResize = 'HEIGHT';
141
+ var x = await closeX(); surf.appendChild(x); x.layoutPositioning = 'ABSOLUTE'; x.x = 300 - 24 - 8; x.y = 8; x.constraints = { horizontal: 'MAX', vertical: 'MIN' };
142
+ surf.clipsContent = true;
143
+ if (a === 'Top') add(c, [arrow('Theme:surface/canvas', 'Theme:border/default', 16, 8)]);
144
+ add(c, [surf]); pops.push(c);
145
+ }
146
+ var popSet = figma.combineAsVariants(pops, figma.currentPage); popSet.name = 'Popover'; row(popSet, 64);
147
+ textProp(popSet, 'Title', 'Trip reminders', 'Title'); textProp(popSet, 'Description', 'We\'ll email you a week before you leave, and again the day before.', 'Description'); boolProp(popSet, 'Close', true, 'Close');
148
+ popSet.description = 'A small panel of content tied to a trigger: a title in title-magnify, a description, and a close X. Padding is popup/padding-panel. The arrow points at the trigger; rotate it for other sides. Wider content such as a calendar or a short form also fits.';
149
+ place(popSet);
150
+
151
+ // Tooltip.
152
+ var tips = [];
153
+ for (var a of ['Bottom', 'None']) {
154
+ var c = comp('Arrow=' + a, 'VERTICAL'); c.counterAxisAlignItems = 'CENTER'; c.itemSpacing = -1;
155
+ var surf = AL('HORIZONTAL', { name: 'Popup' }); bind(surf, ['paddingTop', 'paddingBottom'], 'Scale:space/1-5'); bind(surf, ['paddingLeft', 'paddingRight'], 'Scale:space/2-5');
156
+ fill(surf, 'Theme:surface/inverse'); radius(surf, 'Size:radius/chrome'); await surf.setEffectStyleIdAsync(ES['elevation/lg'].id); surf.clipsContent = true;
157
+ var tx = await T('Copy link', 'body-condense', 'Theme:text/on-inverse-surface'); tx.name = 'Label'; add(surf, [tx]);
158
+ add(c, [surf]); if (a === 'Bottom') add(c, [(function () { var ar = arrow('Theme:surface/inverse', null, 12, 6); ar.rotation = 180; return ar; })()]);
159
+ tips.push(c);
160
+ }
161
+ var tipSet = figma.combineAsVariants(tips, figma.currentPage); tipSet.name = 'Tooltip'; row(tipSet, 64);
162
+ textProp(tipSet, 'Label', 'Copy link', 'Label');
163
+ tipSet.description = 'A short label for a control, on the inverse surface with body-condense text. It labels and never explains at length: it wraps after tooltip/max-width. Never put anything clickable in it.';
164
+ place(tipSet);
165
+ var col = figma.currentPage.children.filter(function (n) { return n.name === 'Components'; })[0];
166
+ return { col: col.id, sets: [mset.id, lset.id, pset.id, menuSet.id, listSet.id, popSet.id, tipSet.id] };
@@ -0,0 +1,90 @@
1
+ // Popups: Menubar trigger, Menubar, Context menu, Preview card. Run after 01-popups.js.
2
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
3
+ var C = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'compHelpers'))(figma);
4
+ var V = C.V, COL = C.COL, TS = C.TS, v = C.v, paint = C.paint, fill = C.fill, stroke = C.stroke, bind = C.bind, radius = C.radius, pad = C.pad, AL = C.AL, add = C.add, T = C.T, mode = C.mode, page = C.page;
5
+ var shadow = C.shadow, focusRing = C.focusRing, icon = C.icon, comp = C.comp, textProp = C.textProp, boolProp = C.boolProp, place = C.place, removeOld = C.removeOld, arrange = C.arrange;
6
+ var here = function (n) { return figma.currentPage.findOne(function (x) { return x.type === 'COMPONENT_SET' && x.name === n; }); };
7
+ await page('Popups');
8
+ ['Context menu', 'Menubar trigger', 'Menubar', 'Preview card'].forEach(function (n) { figma.currentPage.findAll(function (x) { return (x.type === 'COMPONENT_SET' || (x.type === 'FRAME' && x.getSharedPluginData('loomiere', 'wrap') === '1')) && x.name === n; }).forEach(function (x) { x.remove(); }); });
9
+ var ES = {}; (await figma.getLocalEffectStylesAsync()).forEach(function (s) { ES[s.name] = s; });
10
+ var MENU = here('Menu');
11
+ function frameSet(set) { fill(set, 'Theme:surface/canvas'); set.strokes = [{ type: 'SOLID', color: { r: 0.592, g: 0.278, b: 1 } }]; set.dashPattern = [6, 4]; set.strokeWeight = 1; set.cornerRadius = 8; set.clipsContent = false; }
12
+ function grid(set, cols, colW, rowH) {
13
+ set.layoutMode = 'NONE';
14
+ set.children.forEach(function (k, i) { var r = Math.floor(i / cols); k.x = 32 + (i % cols) * colW; k.y = 32 + r * rowH + Math.round((rowH - k.height) / 2); });
15
+ set.resize(64 + cols * colW - 24, 64 + Math.ceil(set.children.length / cols) * rowH); frameSet(set);
16
+ }
17
+ function pick(set, name) { var c = set.children.find(function (x) { return x.name === name; }); if (!c) throw new Error(set.name + ' ' + name); return c.createInstance(); }
18
+ function setP(i, props) { var p = {}; Object.keys(i.componentProperties).forEach(function (k) { var b = k.split('#')[0]; if (props[b] !== undefined) p[k] = props[b]; }); i.setProperties(p); return i; }
19
+ function popupSkin(n) {
20
+ fill(n, 'Theme:surface/canvas'); stroke(n, 'Theme:border/default', 'Size:popup/border-width'); radius(n, 'Size:popup/radius');
21
+ }
22
+ // Menubar trigger
23
+ var mt = [];
24
+ for (var st of ['Default', 'Hover', 'Open', 'Disabled']) {
25
+ var c = comp('State=' + st); c.counterAxisAlignItems = 'CENTER';
26
+ bind(c, ['paddingTop', 'paddingBottom'], 'Size:item/padding-block-dense'); bind(c, ['paddingLeft', 'paddingRight'], 'Size:item/padding-inline'); radius(c, 'Size:item/radius');
27
+ var on = st === 'Hover' || st === 'Open'; if (on) fill(c, 'Theme:interactive/tint-hover');
28
+ var t = await T('File', 'trigger-condense', on ? 'Theme:text/on-tint' : 'Theme:text/primary'); t.name = 'Label'; add(c, [t]);
29
+ if (st === 'Disabled') bind(c, 'opacity', 'Size:disclosure/opacity-disabled');
30
+ mt.push(c);
31
+ }
32
+ var mtSet = figma.combineAsVariants(mt, figma.currentPage); mtSet.name = 'Menubar trigger'; grid(mtSet, 4, 120, 64);
33
+ textProp(mtSet, 'Label', 'File', 'Label');
34
+ mtSet.description = 'A top-level menu name in a Menubar. Hover and Open use the same tint as a highlighted menu item, so the open menu reads as coming from it.';
35
+ // Menubar
36
+ var mb = [];
37
+ for (var content of ['Closed', 'Open']) {
38
+ var c = comp('Content=' + content, 'VERTICAL'); bind(c, 'itemSpacing', 'Scale:space/1'); c.clipsContent = false;
39
+ var bar = AL('HORIZONTAL', { name: 'Bar', counterAxisAlignItems: 'CENTER' }); fill(bar, 'Theme:surface/canvas'); stroke(bar, 'Theme:border/subtle', 'Size:stroke/default'); radius(bar, 'Size:radius/chrome');
40
+ bind(bar, ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight', 'itemSpacing'], 'Scale:space/1');
41
+ var names = ['File', 'Edit', 'View', 'Help'];
42
+ names.forEach(function (n, i) { var tr = pick(mtSet, 'State=' + (content === 'Open' && i === 1 ? 'Open' : 'Default')); setP(tr, { Label: n }); add(bar, [tr]); });
43
+ add(c, [bar]);
44
+ if (content === 'Open') {
45
+ var row = AL('HORIZONTAL', { name: 'Menu row' }); row.paddingLeft = Math.round(bar.children[1].x);
46
+ add(row, [pick(MENU, 'Content=Actions')]); add(c, [row]);
47
+ }
48
+ mb.push(c);
49
+ }
50
+ var mbSet = figma.combineAsVariants(mb, figma.currentPage); mbSet.name = 'Menubar'; grid(mbSet, 2, 320, 280);
51
+ mbSet.description = 'A row of menus, like an app\'s File and Edit. The bar is a quiet chrome surface; each trigger opens a Menu just below it, space/1 down.';
52
+ // Context menu
53
+ var POINTER = 'M 3 2 L 3 14 L 6.5 10.5 L 9 15.5 L 11 14.5 L 8.5 9.5 L 13 9.5 Z';
54
+ var cm = [];
55
+ for (var st of ['Closed', 'Open']) {
56
+ var c = comp('State=' + st); c.layoutMode = 'NONE'; c.resize(400, 300); c.clipsContent = false;
57
+ var tg = AL('HORIZONTAL', { name: 'Target', primaryAxisAlignItems: 'CENTER', counterAxisAlignItems: 'CENTER' }); tg.resize(400, 300); tg.primaryAxisSizingMode = 'FIXED'; tg.counterAxisSizingMode = 'FIXED';
58
+ fill(tg, 'Theme:surface/tray'); stroke(tg, 'Theme:border/default', 'Size:stroke/default'); tg.dashPattern = [4, 4]; radius(tg, 'Size:radius/chrome');
59
+ add(tg, [await T(st === 'Open' ? ' ' : 'Right-click a trip', 'body-condense', 'Theme:text/tertiary')]); c.appendChild(tg);
60
+ var pt = icon('Pointer', POINTER, 16, 'Theme:text/primary', 1.25); pt.children[0].fills = [paint('Theme:surface/canvas')]; c.appendChild(pt); pt.x = st === 'Open' ? 140 : 236; pt.y = st === 'Open' ? 52 : 168;
61
+ if (st === 'Open') { var m = pick(MENU, 'Content=Actions'); c.appendChild(m); m.x = 150; m.y = 64; }
62
+ cm.push(c);
63
+ }
64
+ var cmSet = figma.combineAsVariants(cm, figma.currentPage); cmSet.name = 'Context menu'; grid(cmSet, 2, 440, 340);
65
+ cmSet.description = 'A Menu that opens at the pointer on right-click or long press, over the area that owns it. It is the same Menu, items and tokens as everywhere else.';
66
+ // Preview card
67
+ var MT = 'M 1 13 L 5.5 6 L 8.5 10 L 10.5 7.5 L 15 13 Z';
68
+ var pc = [];
69
+ for (var content of ['Image', 'Text']) {
70
+ var c = comp('Content=' + content, 'VERTICAL'); popupSkin(c); c.clipsContent = true; await c.setEffectStyleIdAsync(ES['elevation/lg'].id);
71
+ c.counterAxisSizingMode = 'FIXED'; c.resize(280, 10); c.primaryAxisSizingMode = 'AUTO'; bind(c, 'width', 'Size:preview-card/popup-min-width');
72
+ if (content === 'Image') {
73
+ var im = AL('HORIZONTAL', { name: 'Image', primaryAxisAlignItems: 'CENTER', counterAxisAlignItems: 'CENTER' }); fill(im, 'Theme:surface/tray');
74
+ add(c, [im]); im.layoutSizingHorizontal = 'FILL'; im.resize(im.width, 128); im.counterAxisSizingMode = 'FIXED';
75
+ add(im, [icon('Picture', MT, 32, 'Theme:text/tertiary', 1.5)]);
76
+ }
77
+ var body = AL('VERTICAL', { name: 'Content' }); bind(body, ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'], 'Size:popup/padding-panel'); bind(body, 'itemSpacing', 'Scale:space/1');
78
+ add(c, [body]); body.layoutSizingHorizontal = 'FILL';
79
+ var tt = await T('Joshua Tree', 'title-magnify', 'Theme:text/primary'); tt.name = 'Title';
80
+ var ds = await T('Granite boulders and Joshua trees where the Mojave meets the Colorado Desert.', 'body-condense', 'Theme:text/secondary'); ds.name = 'Description';
81
+ add(body, [tt, ds]); tt.layoutSizingHorizontal = 'FILL'; ds.layoutSizingHorizontal = 'FILL'; ds.textAutoResize = 'HEIGHT';
82
+ pc.push(c);
83
+ }
84
+ var pcSet = figma.combineAsVariants(pc, figma.currentPage); pcSet.name = 'Preview card'; grid(pcSet, 2, 320, 320);
85
+ pcSet.description = 'A peek at what a link points to, shown on hover. A popup surface with an optional image on top, a title and a line or two of description.';
86
+ var col = figma.currentPage.findOne(function (n) { return n.type === 'FRAME' && n.name === 'Components' && n.parent === figma.currentPage; });
87
+ [mtSet, mbSet, cmSet, pcSet].forEach(function (s) { col.appendChild(s); });
88
+ var over = [];
89
+ [mtSet, mbSet, cmSet, pcSet].forEach(function (s) { s.children.forEach(function (k) { if (k.x + k.width > s.width - 8 || k.y + k.height > s.height - 8 || k.y < 8) over.push(s.name + '/' + k.name + ' ' + Math.round(k.width) + 'x' + Math.round(k.height)); }); });
90
+ return { sets: [mtSet, mbSet, cmSet, pcSet].map(function (s) { return s.name + ' ' + s.id + ' ' + Math.round(s.width) + 'x' + Math.round(s.height) + ' [' + s.children.map(function (k) { return Math.round(k.width) + 'x' + Math.round(k.height); }).join(' ') + ']'; }), over: over };
@@ -0,0 +1,14 @@
1
+ // Popups: a header and row and column labels for each set. Run after 01 and 02.
2
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
3
+ var C = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'compHelpers'))(figma);
4
+ await C.page('Popups');
5
+ // Short header lines; sets not listed use their descriptions.
6
+ return await C.labelSets({
7
+ "Menu item": "A row in a Menu. Action runs something; Checkbox and Radio show a mark when on; Submenu opens another menu. Highlighted is the pointer or keyboard position. Dense padding.",
8
+ "List item": "An option in a Select or Combobox list. Selected is the chosen option: a stronger tint, the selected text style and a check.",
9
+ "Popup part": "The pieces between rows: a group label, a separator, and the message a list shows when nothing matches.",
10
+ Menu: "Actions or options that open from a button. Rows are Menu item instances; swap their Kind and State.",
11
+ "List popup": "The list a Select or Combobox opens under its field, as wide as the field and with the field's corners.",
12
+ Popover: "A small panel tied to a trigger: title, description and a close X. The arrow points at the trigger.",
13
+ Tooltip: "A short label for a control on the inverse surface. Never put anything clickable in it."
14
+ });
@@ -0,0 +1,89 @@
1
+ // Popups doc frame. Run after every Popups set is built and labelled.
2
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
3
+ var L = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'docHelpers'))(figma);
4
+ var V = L.V, COL = L.COL, v = L.v, paint = L.paint, fill = L.fill, stroke = L.stroke, bind = L.bind, radius = L.radius, pad = L.pad, AL = L.AL, add = L.add, T = L.T, mode = L.mode, page = L.page, docFrame = L.docFrame, section = L.section, hrow = L.hrow, divider = L.divider;
5
+ await page('Popups');
6
+ var sets = {}; figma.currentPage.findAllWithCriteria({ types: ['COMPONENT_SET'] }).forEach(function (s) { sets[s.name] = s; });
7
+ function inst(set, name, props) {
8
+ var c = sets[set].children.filter(function (x) { return x.name === name; })[0]; if (!c) throw new Error(set + ' ' + name);
9
+ var i = c.createInstance();
10
+ if (props) { var p = {}; Object.keys(i.componentProperties).forEach(function (k) { var base = k.split('#')[0]; if (props[base] !== undefined) p[k] = props[base]; }); i.setProperties(p); }
11
+ return i;
12
+ }
13
+ async function notes(parent, rows) {
14
+ var tbl = AL('VERTICAL', { name: 'Spec' }); add(parent, [tbl]); tbl.layoutSizingHorizontal = 'FILL';
15
+ var hd = hrow(tbl); hd.paddingTop = 0; divider(hd);
16
+ add(hd, [await T('Part', 'eyebrow-condense', 'Theme:text/tertiary', 180), await T('Token', 'eyebrow-condense', 'Theme:text/tertiary', 340), await T('Why', 'eyebrow-condense', 'Theme:text/tertiary', 640)]);
17
+ for (var i = 0; i < rows.length; i++) {
18
+ var r = hrow(tbl); r.counterAxisAlignItems = 'MIN'; r.paddingTop = r.paddingBottom = 10; divider(r);
19
+ add(r, [await T(rows[i][0], 'subtitle-base', 'Theme:text/primary', 180), await T(rows[i][1], 'code-condense', 'Theme:text/accent', 340), await T(rows[i][2], 'body-condense', 'Theme:text/secondary', 640)]);
20
+ }
21
+ }
22
+ function stage(parent, name) {
23
+ var s = AL('HORIZONTAL', { name: name || 'Examples', itemSpacing: 32, counterAxisAlignItems: 'CENTER', paddingTop: 32, paddingBottom: 32, paddingLeft: 32, paddingRight: 32, layoutWrap: 'WRAP', counterAxisSpacing: 24 });
24
+ fill(s, 'Theme:fill/subtle'); s.cornerRadius = 12; add(parent, [s]); s.layoutSizingHorizontal = 'FILL'; return s;
25
+ }
26
+ async function captioned(node, cap) { var c = AL('VERTICAL', { name: cap, itemSpacing: 10 }); add(c, [node, await T(cap, 'body-condense', 'Theme:text/tertiary')]); return c; }
27
+ async function usage(parent, dos, donts) {
28
+ var r = AL('HORIZONTAL', { name: 'Usage', itemSpacing: 24 }); add(parent, [r]); r.layoutSizingHorizontal = 'FILL';
29
+ var pairs = [['Use it for', dos, 'Theme:status/success'], ['Avoid', donts, 'Theme:status/error']];
30
+ for (var i = 0; i < 2; i++) {
31
+ var col = AL('VERTICAL', { name: pairs[i][0], itemSpacing: 8, paddingLeft: 16 }); add(r, [col]); col.layoutSizingHorizontal = 'FILL';
32
+ col.strokes = [paint(pairs[i][2])]; col.strokeLeftWeight = 3; col.strokeTopWeight = col.strokeRightWeight = col.strokeBottomWeight = 0;
33
+ add(col, [await T(pairs[i][0], 'subtitle-base')]);
34
+ for (var j = 0; j < pairs[i][1].length; j++) { var t = await T(pairs[i][1][j], 'body-base', 'Theme:text/secondary'); add(col, [t]); t.layoutSizingHorizontal = 'FILL'; t.textAutoResize = 'HEIGHT'; }
35
+ }
36
+ }
37
+ figma.currentPage.children.filter(function (n) { return n.name === 'Popups' && n.type === 'FRAME'; }).forEach(function (n) { n.remove(); });
38
+ var doc = await docFrame('Popups', 'COMPONENTS', 'Popups', 'Things that float above the page, tied to the control that opened them: menus, the lists under Select and Combobox, popovers and tooltips. They share one surface (canvas, a default border, the elevation/lg shadow) and one row (the item), so a menu and a select list feel like the same family. Components sit to the right.');
39
+ doc.x = 0; doc.y = 400;
40
+ function M(content) { return inst('Menu', 'Content=' + content); }
41
+
42
+ var pick = await section(doc, 'Choosing a popup', 'Start from what the person is doing: running an action, choosing a value, reading something, or finding out what a control does.');
43
+ var picks = [['Run one of several actions', 'Menu', 'Rename, duplicate, delete. Opens from a button, closes when an action runs.'], ['Turn options on from a button', 'Menu · Options', 'Checkbox and Radio rows: view options, sort order.'], ['Pick one value for a form', 'Select', 'The List popup under a Select field. The value shows in the field after.'], ['Pick from a long or searchable list', 'Combobox', 'The same list, filtered as the person types; Empty when nothing matches.'], ['Show a little content or a short form', 'Popover', 'A title, text and a close X. Stays open until dismissed.'], ['Name an icon-only control', 'Tooltip', 'A word or two on hover and focus. Nothing clickable inside.'], ['Actions for whatever is under the pointer', 'Context menu', 'Right-click or long press. Keep the same actions reachable elsewhere.'], ['An app\'s top-level commands', 'Menubar', 'File, Edit, View. For tools, not site navigation.'], ['A peek at where a link goes', 'Preview card', 'On hover. Nothing in it should be the only way to get somewhere.'], ['Anything that needs the person\'s full attention', 'Dialog', 'On the Overlays page. A popup is easy to dismiss by accident.']];
44
+ var pt = AL('VERTICAL', { name: 'Picker' }); add(pick, [pt]); pt.layoutSizingHorizontal = 'FILL';
45
+ for (var i = 0; i < picks.length; i++) { var r = hrow(pt); divider(r); add(r, [await T(picks[i][0], 'body-base', 'Theme:text/primary', 380), await T(picks[i][1], 'subtitle-base', 'Theme:text/accent', 200), await T(picks[i][2], 'body-condense', 'Theme:text/secondary', 520)]); }
46
+
47
+ var ms = await section(doc, 'Menu', 'A list of actions or options that opens from a button. Each row is a Menu item: Action, Checkbox, Radio or Submenu. Highlighted follows the pointer and the arrow keys. Group related rows with a group label and separate groups with a line.');
48
+ var s1 = stage(ms);
49
+ add(s1, [await captioned(M('Actions'), 'Actions, with a submenu and a disabled row'), await captioned(M('Options'), 'Options: checkbox and radio rows in groups')]);
50
+ var s1b = stage(ms, 'Rows');
51
+ for (var k of ['Action', 'Checkbox', 'Radio', 'Submenu']) add(s1b, [await captioned(inst('Menu item', 'Kind=' + k + ', State=Default'), k)]);
52
+ add(s1b, [await captioned(inst('Menu item', 'Kind=Action, State=Highlighted'), 'Highlighted'), await captioned(inst('Menu item', 'Kind=Action, State=Disabled'), 'Disabled')]);
53
+ await usage(ms, ['Actions on the thing the button belongs to: a row, a card, the page.', 'Short labels that start with a verb.', 'A separator between groups that do different kinds of things, such as destructive actions last.'], ['Choosing a value for a form. That is a Select.', 'Long lists. Past about ten rows, split the menu or use a Combobox.', 'Submenus more than one level deep.']);
54
+ await notes(ms, [['Surface', 'surface/canvas · border/default', 'The same as the page, lifted by the shadow rather than a new color.'], ['Shadow', 'elevation/lg (effect style)', 'High enough to read above cards and fields.'], ['Radius', 'popup/radius', 'Matches cards and panels.'], ['Padding', 'popup/padding', 'A thin inset so highlighted rows do not touch the edge.'], ['Width', 'popup/min-width', 'Grows with the longest label.'], ['Row', 'item/padding-block-dense · item/padding-inline · item/gap', 'Menu rows are a little tighter than list options.'], ['Highlight', 'interactive/tint-hover · text/on-tint', 'The same tint as a hovered field option.'], ['Mark', 'item/indicator-size · interactive/default', 'Check and dot sit in a fixed slot so labels line up.'], ['Group label', 'eyebrow-condense · text/secondary', 'popup/group-label-padding-top and -bottom.'], ['Separator', 'border/subtle · popup/separator-spacing', 'A hairline with space above and below.'], ['Disabled', 'item/opacity-disabled', 'The row fades as a whole.']]);
55
+
56
+ var ls = await section(doc, 'Select and Combobox lists', 'The list that opens under a Select or Combobox field. It is as wide as the field and uses the field\'s corners (popup/radius-field), so the two read as one control. The chosen option is Selected: a stronger tint, the selected text style and a check.');
57
+ var s2 = stage(ls);
58
+ add(s2, [await captioned(inst('List popup', 'Content=Options'), 'Options'), await captioned(inst('List popup', 'Content=Grouped'), 'Grouped'), await captioned(inst('List popup', 'Content=Empty'), 'Empty (Combobox)')]);
59
+ var s2b = stage(ls, 'Rows');
60
+ for (var st of ['Default', 'Highlighted', 'Selected', 'Disabled']) add(s2b, [await captioned(inst('List item', 'State=' + st), st)]);
61
+ await usage(ls, ['Select for a short, fixed set of values.', 'Combobox when the list is long or people know what they are looking for.', 'Group labels when the options fall into clear sets.'], ['Actions in the list. Options set a value; a Menu runs actions.', 'A list so long it scrolls a lot without a way to search.']);
62
+ await notes(ls, [['Radius', 'popup/radius-field', 'The field\'s corners, so field and list match.'], ['Height', 'popup/list-max-height', 'Scrolls after this.'], ['Row', 'item/padding-block · item/padding-inline', 'Regular padding: easier to hit than menu rows.'], ['Selected', 'interactive/tint-active · text/on-tint-strong · selected-base', 'Stronger than the hover tint, plus a check, so color is not the only signal.'], ['Empty', 'body-condense · text/tertiary', 'popup/empty-padding-block above and below.']]);
63
+
64
+ var ps = await section(doc, 'Popover', 'A small panel of content tied to a trigger. It has a title, a description and a close X, and stays open until dismissed. The arrow points at the trigger; rotate it for other sides, or turn it off when the popover sits flush under a field.');
65
+ var s3 = stage(ps);
66
+ add(s3, [await captioned(inst('Popover', 'Arrow=Top'), 'With arrow'), await captioned(inst('Popover', 'Arrow=None'), 'No arrow')]);
67
+ await usage(ps, ['Extra detail about something on the page.', 'A short form: a date, a filter, a rename.', 'A calendar or color picker under a field.'], ['Anything the person must deal with before going on. That is a Dialog.', 'A single short label. That is a Tooltip.']);
68
+ await notes(ps, [['Padding', 'popup/padding-panel', 'More room than a menu, because it holds text.'], ['Title', 'title-magnify', 'The same tier as a card title.'], ['Text', 'body-base · text/secondary', 'Secondary so the title leads.'], ['Close', 'icon-button/size · text/tertiary', 'The standard quiet X, top right.'], ['Surface', 'surface/canvas · border/default · elevation/lg · popup/radius', 'The shared popup surface.']]);
69
+
70
+ var tps = await section(doc, 'Tooltip', 'A short label for a control, shown on hover and keyboard focus. It sits on the inverse surface so it reads as separate from the page. It labels and never explains at length.');
71
+ var s4 = stage(tps);
72
+ add(s4, [await captioned(inst('Tooltip', 'Arrow=Bottom'), 'With arrow'), await captioned(inst('Tooltip', 'Arrow=None'), 'No arrow')]);
73
+ await usage(tps, ['Naming an icon-only button.', 'Showing a keyboard shortcut.', 'The full text of something cut off.'], ['Information people need to finish a task. Touch screens have no hover.', 'Links or buttons inside. Use a Popover.']);
74
+ await notes(tps, [['Surface', 'surface/inverse · text/on-inverse-surface', 'Dark on a light theme, light on a dark one.'], ['Text', 'body-condense', 'One step smaller than body.'], ['Padding', 'space/1-5 · space/2-5', 'Tight, so it stays small.'], ['Radius', 'radius/chrome', 'Slightly rounded, like other chrome.'], ['Width', 'tooltip/max-width', 'Wraps after this.']]);
75
+
76
+ // Sections for the later sets use the shared doc kit; its stages top-align their examples.
77
+ var K = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'docKit'))(figma);
78
+ var cmm = await section(doc, 'Context menu and Menubar', 'Two more ways to open the same Menu. A context menu opens at the pointer on right-click or long press, with actions for what is under it. A menubar is a row of menus, like an app\'s File and Edit; once one is open, moving across the bar opens the next.');
79
+ var k1 = K.stage(cmm, 'Examples');
80
+ add(k1, [await K.captioned(K.inst('Context menu', 'State=Open'), 'Context menu'), await K.captioned(K.inst('Menubar', 'Content=Open'), 'Menubar, Edit open')]);
81
+ var k2 = K.stage(cmm, 'Menubar trigger');
82
+ for (var st of ['Default', 'Hover', 'Open', 'Disabled']) add(k2, [await K.captioned(K.inst('Menubar trigger', 'State=' + st), st)]);
83
+ await K.usage(cmm, ['A context menu as a shortcut to actions that are also reachable some other way.', 'A menubar in tool-like apps with many commands.'], ['Context menus as the only way to do something; touch and keyboard users may never find them.', 'A menubar for site navigation. Use Navigation menu.']);
84
+ await K.notes(cmm, [['Bar', 'surface/canvas · border/subtle · radius/chrome', 'Quiet chrome, so the menus lead.'], ['Bar padding', 'space/1', 'Padding and gap between triggers.'], ['Trigger', 'trigger-condense · item/padding-inline · item/padding-block-dense', 'The same size as a dense menu item.'], ['Open trigger', 'interactive/tint-hover · text/on-tint', 'The highlighted item tint, so the menu reads as coming from it.'], ['Menu', 'Menu (this page)', 'Context menus and menubars use the same Menu.']]);
85
+ var pcs = await section(doc, 'Preview card', 'A peek at what a link points to, shown when the pointer rests on it. A popup surface with an optional image on top, then a title and a line or two of description. It is extra: everything in it must also be reachable by following the link.');
86
+ var k3 = K.stage(pcs, 'Content');
87
+ add(k3, [await K.captioned(K.inst('Preview card', 'Content=Image'), 'With image'), await K.captioned(K.inst('Preview card', 'Content=Text'), 'Text only')]);
88
+ await K.notes(pcs, [['Surface', 'surface/canvas · border/default · popup/radius · elevation/lg', 'The same popup surface as Popover.'], ['Width', 'preview-card/popup-min-width · popup-max-width', 'Wide enough for an image, narrow enough to stay a peek.'], ['Image', 'surface/tray placeholder', 'Clipped by the card corners.'], ['Padding', 'popup/padding-panel', 'Around the text.'], ['Text', 'title-magnify · body-condense text/secondary', 'A title and one or two lines.']]);
89
+ return { id: doc.id, h: Math.round(doc.height) };