@firedrill-tools/linkedin 0.1.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 (111) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +189 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +1834 -0
  5. package/firedrill/comment-throttled.scenario.json +11 -0
  6. package/firedrill/conformance.suite.json +27 -0
  7. package/firedrill/linkedin-comment-throttled.drill.json +51 -0
  8. package/firedrill/linkedin-comments-connection.drill.json +43 -0
  9. package/firedrill/linkedin-comments.drill.json +196 -0
  10. package/firedrill/linkedin-denied.drill.json +383 -0
  11. package/firedrill/linkedin-fresh-install.drill.json +43 -0
  12. package/firedrill/linkedin-identity.drill.json +73 -0
  13. package/firedrill/linkedin-invalid-token.drill.json +393 -0
  14. package/firedrill/linkedin-limited-scopes.drill.json +88 -0
  15. package/firedrill/linkedin-openid-only.drill.json +383 -0
  16. package/firedrill/linkedin-orgs-connection.drill.json +28 -0
  17. package/firedrill/linkedin-orgs-network.drill.json +193 -0
  18. package/firedrill/linkedin-post-lost-response.drill.json +61 -0
  19. package/firedrill/linkedin-posts-lifecycle.drill.json +228 -0
  20. package/firedrill/linkedin-reactions-metadata.drill.json +196 -0
  21. package/firedrill/linkedin-reactions-outage.drill.json +66 -0
  22. package/firedrill/linkedin-tight-limits.drill.json +228 -0
  23. package/firedrill/linkedin-ugc-legacy.drill.json +43 -0
  24. package/firedrill/linkedin-visibility-connection.drill.json +73 -0
  25. package/firedrill/linkedin-visibility-stranger.drill.json +88 -0
  26. package/firedrill/linkedin-wire-errors.drill.json +353 -0
  27. package/firedrill/post-lost-response.scenario.json +11 -0
  28. package/firedrill/reactions-outage.scenario.json +11 -0
  29. package/firedrill/tight-limits.scenario.json +16 -0
  30. package/firedrill/tools/linkedin/app/assets/ATTRIBUTION.md +28 -0
  31. package/firedrill/tools/linkedin/app/assets/fonts/OFL.txt +93 -0
  32. package/firedrill/tools/linkedin/app/assets/linkedin-in.svg +1 -0
  33. package/firedrill/tools/linkedin/app/assets/linkedin-wordmark.svg +1 -0
  34. package/firedrill/tools/linkedin/app/assets/linkedin.svg +1 -0
  35. package/firedrill/tools/linkedin/app/site/app.js +80 -0
  36. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-400-normal.woff2 +0 -0
  37. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-600-normal.woff2 +0 -0
  38. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-700-normal.woff2 +0 -0
  39. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-ext-400-normal.woff2 +0 -0
  40. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-ext-600-normal.woff2 +0 -0
  41. package/firedrill/tools/linkedin/app/site/assets/fonts/fira-sans-latin-ext-700-normal.woff2 +0 -0
  42. package/firedrill/tools/linkedin/app/site/assets/linkedin-wordmark.svg +1 -0
  43. package/firedrill/tools/linkedin/app/site/assets/linkedin.svg +1 -0
  44. package/firedrill/tools/linkedin/app/site/base.css +87 -0
  45. package/firedrill/tools/linkedin/app/site/comment-box.js +119 -0
  46. package/firedrill/tools/linkedin/app/site/comments.css +59 -0
  47. package/firedrill/tools/linkedin/app/site/comments.js +188 -0
  48. package/firedrill/tools/linkedin/app/site/company.js +115 -0
  49. package/firedrill/tools/linkedin/app/site/composer.js +112 -0
  50. package/firedrill/tools/linkedin/app/site/feed.css +105 -0
  51. package/firedrill/tools/linkedin/app/site/feed.js +134 -0
  52. package/firedrill/tools/linkedin/app/site/icons.js +70 -0
  53. package/firedrill/tools/linkedin/app/site/index.html +58 -0
  54. package/firedrill/tools/linkedin/app/site/nav.css +51 -0
  55. package/firedrill/tools/linkedin/app/site/network.js +77 -0
  56. package/firedrill/tools/linkedin/app/site/overlay.css +62 -0
  57. package/firedrill/tools/linkedin/app/site/overlay.js +108 -0
  58. package/firedrill/tools/linkedin/app/site/pages.css +78 -0
  59. package/firedrill/tools/linkedin/app/site/post-actions.js +83 -0
  60. package/firedrill/tools/linkedin/app/site/post-menu.js +72 -0
  61. package/firedrill/tools/linkedin/app/site/post.css +82 -0
  62. package/firedrill/tools/linkedin/app/site/post.js +131 -0
  63. package/firedrill/tools/linkedin/app/site/profile.js +139 -0
  64. package/firedrill/tools/linkedin/app/site/reactions.js +46 -0
  65. package/firedrill/tools/linkedin/app/site/reactors.js +71 -0
  66. package/firedrill/tools/linkedin/app/site/shell.js +107 -0
  67. package/firedrill/tools/linkedin/app/site/store.js +62 -0
  68. package/firedrill/tools/linkedin/app/site/text.js +80 -0
  69. package/firedrill/tools/linkedin/app/site/ui.js +142 -0
  70. package/firedrill/tools/linkedin/behavior.mjs +66 -0
  71. package/firedrill/tools/linkedin/lib/access.mjs +69 -0
  72. package/firedrill/tools/linkedin/lib/auth.mjs +115 -0
  73. package/firedrill/tools/linkedin/lib/cascade.mjs +28 -0
  74. package/firedrill/tools/linkedin/lib/errors.mjs +50 -0
  75. package/firedrill/tools/linkedin/lib/message.mjs +43 -0
  76. package/firedrill/tools/linkedin/lib/paging.mjs +54 -0
  77. package/firedrill/tools/linkedin/lib/postinput.mjs +47 -0
  78. package/firedrill/tools/linkedin/lib/render.mjs +95 -0
  79. package/firedrill/tools/linkedin/lib/routes.mjs +219 -0
  80. package/firedrill/tools/linkedin/lib/social.mjs +25 -0
  81. package/firedrill/tools/linkedin/lib/store.mjs +54 -0
  82. package/firedrill/tools/linkedin/lib/text.mjs +55 -0
  83. package/firedrill/tools/linkedin/lib/urn.mjs +93 -0
  84. package/firedrill/tools/linkedin/lib/wire.mjs +125 -0
  85. package/firedrill/tools/linkedin/linkedin.tool.json +8935 -0
  86. package/firedrill/tools/linkedin/ops/comments-read.mjs +57 -0
  87. package/firedrill/tools/linkedin/ops/comments-write.mjs +104 -0
  88. package/firedrill/tools/linkedin/ops/feed.mjs +74 -0
  89. package/firedrill/tools/linkedin/ops/network.mjs +46 -0
  90. package/firedrill/tools/linkedin/ops/orgs.mjs +93 -0
  91. package/firedrill/tools/linkedin/ops/posts-edit.mjs +71 -0
  92. package/firedrill/tools/linkedin/ops/posts-read.mjs +54 -0
  93. package/firedrill/tools/linkedin/ops/posts-write.mjs +74 -0
  94. package/firedrill/tools/linkedin/ops/profile.mjs +38 -0
  95. package/firedrill/tools/linkedin/ops/reactions.mjs +78 -0
  96. package/firedrill/tools/linkedin/ops/social.mjs +34 -0
  97. package/firedrill/world.json +2585 -0
  98. package/firedrill.json +5 -0
  99. package/package.json +52 -0
  100. package/starter.json +1833 -0
  101. package/test/client.mjs +71 -0
  102. package/test/conformance.mjs +20 -0
  103. package/test/fixtures.mjs +182 -0
  104. package/test/flows/comments.mjs +116 -0
  105. package/test/flows/every.mjs +38 -0
  106. package/test/flows/faults.mjs +56 -0
  107. package/test/flows/identity.mjs +70 -0
  108. package/test/flows/orgs.mjs +107 -0
  109. package/test/flows/posts.mjs +196 -0
  110. package/test/flows/reactions.mjs +101 -0
  111. package/test/flows/wire.mjs +62 -0
@@ -0,0 +1,77 @@
1
+ // My Network > Connections: count header, search, "Recently added" list with paging.
2
+ import { el, call, describe, avatar, longDate, busy } from "./ui.js";
3
+ import { icon } from "./icons.js";
4
+ import { footer } from "./feed.js";
5
+
6
+ const PAGE = 10;
7
+
8
+ export function renderNetwork(main) {
9
+ const header = el("h1", { class: "network__count", text: "Connections" });
10
+ const input = el("input", { class: "network__search-input", attrs: { id: "network-search", type: "search", placeholder: "Search by name", autocomplete: "off" } });
11
+ const list = el("ul", { class: "network__list", attrs: { "aria-live": "polite" } });
12
+ const more = el("div", { class: "network__more" });
13
+ let query = "";
14
+ let loaded = 0;
15
+ let token = 0;
16
+
17
+ const load = async (reset) => {
18
+ const mine = ++token;
19
+ if (reset) { loaded = 0; list.replaceChildren(el("li", { class: "network__loading" }, el("span", { class: "spinner", attrs: { "aria-label": "Loading connections" } }))); }
20
+ more.replaceChildren();
21
+ try {
22
+ const page = await call("connections.list", { query, start: loaded, count: PAGE });
23
+ if (mine !== token) return;
24
+ if (reset) list.replaceChildren();
25
+ const total = page.paging.total ?? 0;
26
+ if (!query) header.textContent = `${total} Connection${total === 1 ? "" : "s"}`;
27
+ for (const { person, connectedAt } of page.elements) {
28
+ const name = `${person.localizedFirstName} ${person.localizedLastName}`.trim();
29
+ const href = `#/in/${encodeURIComponent(person.id)}`;
30
+ list.append(el("li", { class: "network__row" }, [
31
+ el("a", { attrs: { href, tabindex: "-1", "aria-hidden": "true" } }, avatar(name, `urn:li:person:${person.id}`, 72)),
32
+ el("div", { class: "network__meta" }, [
33
+ el("a", { class: "network__name", text: name, attrs: { href } }),
34
+ person.localizedHeadline ? el("span", { class: "network__headline", text: person.localizedHeadline }) : null,
35
+ el("span", { class: "network__date", text: `Connected on ${longDate(connectedAt)}` }),
36
+ ]),
37
+ el("div", { class: "network__actions" }, [
38
+ el("button", { class: "btn btn--secondary btn--sm", text: "Message", attrs: { type: "button", "data-not-simulated": "Messaging" } }),
39
+ el("button", { class: "icon-btn", title: "More actions", attrs: { type: "button", "aria-label": `More actions for ${name}`, "data-not-simulated": "Removing connections" } }, icon("ellipsis")),
40
+ ]),
41
+ ]));
42
+ }
43
+ loaded += page.elements.length;
44
+ if (loaded === 0) list.append(el("li", { class: "network__empty" }, query
45
+ ? [el("p", { class: "t-bold", text: "No results found" }), el("p", { class: "muted", text: "Try searching for a different name." })]
46
+ : [el("p", { class: "t-bold", text: "You don't have any connections yet" }), el("p", { class: "muted", text: "Connections you make will appear here." })]));
47
+ if (loaded < total) more.append(el("button", { class: "btn btn--tertiary", text: "Show more results", attrs: { type: "button" }, on: { click: (e) => busy(e.currentTarget, () => load(false)) } }));
48
+ } catch (error) {
49
+ if (mine !== token) return;
50
+ list.replaceChildren(el("li", { class: "network__empty", attrs: { role: "alert" } }, [el("p", { class: "t-bold", text: "Couldn't load your connections" }), el("p", { class: "muted", text: describe(error) }), el("button", { class: "btn btn--secondary btn--sm", text: "Retry", attrs: { type: "button" }, on: { click: () => load(true) } })]));
51
+ }
52
+ };
53
+
54
+ let timer;
55
+ input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => { query = input.value.trim().slice(0, 200); void load(true); }, 200); });
56
+
57
+ const manage = el("nav", { class: "card network-manage", attrs: { "aria-label": "Manage my network" } }, [
58
+ el("h2", { class: "network-manage__title", text: "Manage my network" }),
59
+ el("a", { class: "network-manage__item network-manage__item--active", attrs: { href: "#/mynetwork", "aria-current": "page" } }, [icon("nav-network"), el("span", { text: "Connections" })]),
60
+ ...[["people", "Following & followers"], ["group", "Groups"], ["calendar", "Events"], ["nav-grid", "Pages"], ["newsletter", "Newsletters"]].map(([glyph, label]) =>
61
+ el("button", { class: "network-manage__item", attrs: { type: "button", "data-not-simulated": label } }, [icon(glyph), el("span", { text: label })])),
62
+ ]);
63
+
64
+ main.replaceChildren(el("div", { class: "network-layout" }, [
65
+ el("aside", {}, [manage, footer()]),
66
+ el("section", { class: "card network" }, [
67
+ header,
68
+ el("div", { class: "network__toolbar" }, [
69
+ el("span", { class: "network__sort" }, [el("span", { class: "muted", text: "Sort by: " }), el("button", { class: "network__sort-btn", attrs: { type: "button", "data-not-simulated": "Other connection sort orders" } }, [el("span", { text: "Recently added" }), icon("caret-down")])]),
70
+ el("div", { class: "network__search" }, [icon("search"), el("label", { class: "visually-hidden", text: "Search connections by name", attrs: { for: "network-search" } }), input]),
71
+ ]),
72
+ list, more,
73
+ ]),
74
+ ]));
75
+ void load(true);
76
+ return { refresh: () => load(true) };
77
+ }
@@ -0,0 +1,62 @@
1
+ .modal-scrim { position: fixed; inset: 0; z-index: 40; display: flex; align-items: flex-start; justify-content: center; padding: 48px 16px; background: rgba(0, 0, 0, 0.75); overflow-y: auto; }
2
+ .modal { width: 100%; max-width: 552px; background: #fff; border-radius: 8px; box-shadow: 0 0 0 1px var(--hairline), 0 4px 16px rgba(0, 0, 0, .3); display: flex; flex-direction: column; max-height: calc(100vh - 96px); }
3
+ .modal__header { display: flex; align-items: center; justify-content: space-between; padding: 12px 12px 12px 24px; border-bottom: 1px solid var(--divider); }
4
+ .modal__title { font-size: 20px; line-height: 1.4; font-weight: 400; color: var(--text); }
5
+ .modal__body { padding: 16px 24px; overflow-y: auto; }
6
+ .modal__footer { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--divider); }
7
+ .modal__footer:empty { display: none; }
8
+ .modal--confirm { max-width: 480px; margin-top: 15vh; }
9
+ .confirm__text { font-size: 14px; color: var(--text); }
10
+ .not-sim { display: flex; gap: 12px; }
11
+ .not-sim__icon { color: var(--blue); }
12
+ .not-sim p + p { margin-top: 4px; }
13
+
14
+ .modal--composer { max-width: 744px; min-height: 480px; }
15
+ .modal--composer .modal__header { border-bottom: 0; }
16
+ .modal--composer .modal__header { padding: 16px 12px 0 16px; align-items: flex-start; }
17
+ .composer__who { display: inline-flex; align-items: center; gap: 12px; padding: 8px 12px 8px 8px; border: 0; border-radius: 12px; background: none; text-align: left; cursor: pointer; }
18
+ .composer__who:hover:not(:disabled) { background: var(--hover); }
19
+ .composer__who:disabled { cursor: default; }
20
+ .composer__who-text { display: flex; flex-direction: column; }
21
+ .composer__who-name { display: flex; align-items: center; gap: 2px; font-size: 20px; font-weight: 600; line-height: 1.4; }
22
+ .composer__who-vis { display: flex; align-items: center; gap: 4px; font-size: 14px; color: var(--text); }
23
+ .composer__who-vis .icon { width: 16px; height: 16px; }
24
+ .composer__text { display: block; width: 100%; min-height: 240px; margin-top: 4px; padding: 0; border: 0; resize: vertical; font-size: 18px; line-height: 1.5; color: var(--text); background: transparent; }
25
+ .composer__text:focus { outline: none; }
26
+ .composer__text::placeholder { color: rgba(0, 0, 0, 0.6); }
27
+ .composer__embed { margin-top: 8px; padding: 12px; border: 1px solid #e0dfdc; border-radius: 8px; }
28
+ .composer__embed-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
29
+ .composer__embed-text { color: var(--text); white-space: pre-wrap; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
30
+ .composer__link { display: grid; gap: 4px; margin-top: 12px; padding: 12px; border-radius: 8px; background: #f3f6f8; }
31
+ .composer__error { margin-top: 12px; padding: 8px 12px; border-radius: 4px; background: #fce8e6; color: #b3080f; font-size: 14px; }
32
+ .modal--composer .modal__footer { justify-content: space-between; border-top: 0; padding: 8px 24px 16px; }
33
+ .composer__tools { display: flex; gap: 4px; }
34
+ .composer__tools .icon-btn { color: var(--text-2); }
35
+ .composer__submit-row { display: flex; align-items: center; gap: 12px; }
36
+ .composer__counter { font-size: 14px; color: var(--text-2); }
37
+ .composer__counter--over { color: var(--danger); font-weight: 600; }
38
+
39
+ .field__label { font-size: 14px; color: var(--text-2); margin-top: 4px; }
40
+ .field__input { width: 100%; height: 32px; padding: 4px 8px; border: 1px solid rgba(0, 0, 0, .6); border-radius: 4px; background: #fff; font-size: 14px; }
41
+ .field__input:focus { outline: none; box-shadow: inset 0 0 0 1px #000; }
42
+
43
+ .reactors__tabs { display: flex; gap: 0; border-bottom: 1px solid var(--divider); margin: -16px -24px 0; padding: 0 16px; overflow-x: auto; }
44
+ .reactors__tab { display: inline-flex; align-items: center; gap: 4px; padding: 12px; border: 0; background: none; font-weight: 600; color: var(--text-2); cursor: pointer; border-bottom: 2px solid transparent; }
45
+ .reactors__tab[aria-selected="true"] { color: var(--green); border-bottom-color: var(--green); }
46
+ .reactors__tab:hover { background: var(--hover); }
47
+ .reactors__list { margin: 0 -24px; }
48
+ .reactors__row { display: flex; align-items: center; gap: 12px; padding: 8px 24px; }
49
+ .reactors__avatar { position: relative; }
50
+ .reactors__avatar .reaction-badge { position: absolute; right: -2px; bottom: -2px; border: 2px solid #fff; border-radius: 50%; box-sizing: content-box; }
51
+ .reactors__meta { min-width: 0; flex: 1; border-bottom: 1px solid var(--divider); padding-bottom: 8px; }
52
+ .reactors__name { font-weight: 600; color: var(--text); }
53
+ .reactors__sub { font-size: 12px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
54
+ .modal--reactors .modal__body { min-height: 320px; }
55
+
56
+ .toast { position: fixed; left: 24px; bottom: 24px; z-index: 60; display: flex; align-items: center; gap: 8px; min-width: 320px; max-width: 480px; padding: 12px 8px 12px 16px; border-radius: 8px; background: #fff; box-shadow: 0 0 0 1px var(--hairline), 0 4px 16px rgba(0, 0, 0, .2); font-size: 14px; color: var(--text); animation: pop .15s ease-out; }
57
+ .toast::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; border-radius: 8px 0 0 8px; background: #057642; }
58
+ .toast[data-error="true"]::before { background: var(--danger); }
59
+ .toast__icon { color: #057642; }
60
+ .toast[data-error="true"] .toast__icon { color: var(--danger); }
61
+ .toast__text { flex: 1; }
62
+ .toast__action { border: 0; background: none; color: var(--blue); font-weight: 600; cursor: pointer; }
@@ -0,0 +1,108 @@
1
+ // Modals, confirmation dialogs, dropdown menus and the "not simulated" panel.
2
+ import { $, el, iconButton } from "./ui.js";
3
+ import { icon } from "./icons.js";
4
+
5
+ let openModalState = null;
6
+
7
+ /** Open a modal dialog. Returns { body, footer, close }. */
8
+ export function openModal({ title, className = "", onClose, labelledBy } = {}) {
9
+ closeModal();
10
+ const titleId = `modal-title-${Math.random().toString(36).slice(2)}`;
11
+ const heading = el("h2", { class: "modal__title", text: title, attrs: { id: titleId } });
12
+ const closeButton = iconButton("close", "Dismiss", () => closeModal());
13
+ const body = el("div", { class: "modal__body" });
14
+ const footer = el("div", { class: "modal__footer" });
15
+ const dialog = el("div", { class: `modal ${className}`.trim(), attrs: { role: "dialog", "aria-modal": "true", "aria-labelledby": labelledBy ?? titleId } }, [
16
+ el("div", { class: "modal__header" }, [heading, closeButton]), body, footer,
17
+ ]);
18
+ const scrim = el("div", { class: "modal-scrim", on: { mousedown: (event) => { if (event.target === scrim) closeModal(); } } }, dialog);
19
+ const previous = document.activeElement;
20
+ const onKey = (event) => { if (event.key === "Escape") { event.preventDefault(); closeModal(); } };
21
+ document.addEventListener("keydown", onKey);
22
+ $("#overlay-root").append(scrim);
23
+ openModalState = { scrim, onKey, previous, onClose };
24
+ queueMicrotask(() => (dialog.querySelector("textarea, input, [data-autofocus]") ?? closeButton).focus());
25
+ return { dialog, body, footer, heading, close: closeModal };
26
+ }
27
+
28
+ export function closeModal() {
29
+ if (!openModalState) return;
30
+ const { scrim, onKey, previous, onClose } = openModalState;
31
+ openModalState = null;
32
+ document.removeEventListener("keydown", onKey);
33
+ scrim.remove();
34
+ if (onClose) onClose();
35
+ if (previous && typeof previous.focus === "function" && document.contains(previous)) previous.focus();
36
+ }
37
+ export const modalOpen = () => openModalState !== null;
38
+
39
+ /** LinkedIn-style confirmation: resolves true when the primary button is chosen. */
40
+ export function confirmDialog({ title, message, confirmLabel, cancelLabel = "Cancel", danger = false }) {
41
+ return new Promise((resolve) => {
42
+ let answered = false;
43
+ const modal = openModal({ title, className: "modal--confirm", onClose: () => { if (!answered) resolve(false); } });
44
+ modal.body.append(el("p", { class: "confirm__text", text: message }));
45
+ const cancel = el("button", { class: "btn btn--muted", text: cancelLabel, attrs: { type: "button" }, on: { click: () => closeModal() } });
46
+ const ok = el("button", { class: `btn ${danger ? "btn--primary" : "btn--primary"}`, text: confirmLabel, attrs: { type: "button", "data-autofocus": true }, on: { click: () => { answered = true; closeModal(); resolve(true); } } });
47
+ modal.footer.append(cancel, ok);
48
+ });
49
+ }
50
+
51
+ /** Explain that a visible control belongs to a part of the product this Tool does not simulate. */
52
+ export function notSimulated(feature) {
53
+ const modal = openModal({ title: feature, className: "modal--confirm" });
54
+ modal.body.append(
55
+ el("div", { class: "not-sim" }, [
56
+ el("span", { class: "not-sim__icon" }, icon("info")),
57
+ el("div", {}, [
58
+ el("p", { class: "t-bold", text: "Not simulated by this Tool" }),
59
+ el("p", { class: "muted", text: `${feature} is part of the real product but is outside this Firedrill Tool's scope. Nothing was changed and no data is shown for it.` }),
60
+ ]),
61
+ ]),
62
+ );
63
+ modal.footer.append(el("button", { class: "btn btn--primary", text: "Got it", attrs: { type: "button", "data-autofocus": true }, on: { click: () => closeModal() } }));
64
+ }
65
+
66
+ let openMenu = null;
67
+ export function closeMenu() {
68
+ if (!openMenu) return;
69
+ openMenu.menu.remove();
70
+ openMenu.trigger.setAttribute("aria-expanded", "false");
71
+ document.removeEventListener("mousedown", openMenu.outside, true);
72
+ document.removeEventListener("keydown", openMenu.onKey, true);
73
+ openMenu = null;
74
+ }
75
+
76
+ /** Dropdown menu anchored under a trigger. items: [{icon, label, sub, onSelect, disabled}] */
77
+ export function showMenu(trigger, items, { align = "right" } = {}) {
78
+ const wasOpen = openMenu?.trigger === trigger;
79
+ closeMenu();
80
+ if (wasOpen) return;
81
+ const menu = el("div", { class: `dropdown dropdown--${align}`, attrs: { role: "menu" } });
82
+ for (const item of items) {
83
+ const button = el("button", { class: "dropdown__item", attrs: { type: "button", role: "menuitem", disabled: item.disabled ? true : undefined }, on: { click: () => { closeMenu(); item.onSelect?.(); } } }, [
84
+ item.icon ? icon(item.icon) : null,
85
+ el("span", { class: "dropdown__text" }, [el("span", { class: "dropdown__label", text: item.label }), item.sub ? el("span", { class: "dropdown__sub", text: item.sub }) : null]),
86
+ ]);
87
+ menu.append(button);
88
+ }
89
+ trigger.parentElement.style.position = trigger.parentElement.style.position || "relative";
90
+ trigger.after(menu);
91
+ trigger.setAttribute("aria-expanded", "true");
92
+ const outside = (event) => { if (!menu.contains(event.target) && event.target !== trigger && !trigger.contains(event.target)) closeMenu(); };
93
+ const onKey = (event) => { if (event.key === "Escape") { closeMenu(); trigger.focus(); } };
94
+ document.addEventListener("mousedown", outside, true);
95
+ document.addEventListener("keydown", onKey, true);
96
+ openMenu = { menu, trigger, outside, onKey };
97
+ menu.querySelector("button:not([disabled])")?.focus();
98
+ }
99
+
100
+ /** Delegate every [data-not-simulated] control in the document to the panel. */
101
+ export function wireNotSimulated() {
102
+ document.addEventListener("click", (event) => {
103
+ const target = event.target instanceof Element ? event.target.closest("[data-not-simulated]") : null;
104
+ if (!target) return;
105
+ event.preventDefault();
106
+ notSimulated(target.getAttribute("data-not-simulated"));
107
+ });
108
+ }
@@ -0,0 +1,78 @@
1
+ .profile-layout { display: grid; grid-template-columns: minmax(0, 804px) 300px; gap: 24px; justify-content: center; align-items: start; }
2
+ .profile-main > * + *, .company-main > * + * { margin-top: 8px; }
3
+ .profile-aside > * + * { margin-top: 8px; }
4
+ .aside-card { padding: 16px 24px; }
5
+ .aside-card__title { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
6
+
7
+ .profile-top { position: relative; overflow: hidden; }
8
+ .profile-top__loading { display: flex; justify-content: center; padding: 64px; }
9
+ .profile-top__cover { height: 201px; background: linear-gradient(120deg, #a0b4b7 0%, #c8d4d6 45%, #e6ecec 100%); }
10
+ .profile-top__cover--company { height: 170px; background: linear-gradient(120deg, #9db3c8 0%, #c7d6e4 55%, #e3ebf2 100%); }
11
+ .profile-top__body { position: relative; padding: 0 24px 24px; }
12
+ .profile-top__avatar { margin-top: -112px; margin-bottom: 12px; }
13
+ .profile-top__avatar .avatar { box-shadow: none; }
14
+ .profile-top__edit { position: absolute; top: 12px; right: 16px; }
15
+ .profile-top__name { font-size: 24px; line-height: 1.25; font-weight: 600; }
16
+ .profile-top__headline { margin-top: 2px; font-size: 16px; line-height: 1.5; color: var(--text); }
17
+ .profile-top__meta { margin-top: 8px; font-size: 14px; color: var(--text-2); }
18
+ .profile-top__contact { padding: 0; border: 0; background: none; color: var(--blue); font-weight: 600; cursor: pointer; }
19
+ .profile-top__contact:hover { text-decoration: underline; }
20
+ .profile-top__connections { margin-top: 4px; font-weight: 600; }
21
+ .profile-top__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
22
+ .contact__title { font-size: 20px; font-weight: 600; margin-bottom: 12px; }
23
+ .contact__row { display: flex; gap: 16px; margin-bottom: 16px; }
24
+ .company-note { padding: 12px 24px; border-top: 1px solid var(--divider); color: var(--text-2); }
25
+
26
+ .section-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 12px 24px 0; }
27
+ .section-head__title { font-size: 20px; line-height: 1.4; font-weight: 600; }
28
+ .section-head__sub { display: block; font-size: 14px; font-weight: 600; }
29
+ .profile-activity { padding-bottom: 8px; }
30
+ .profile-activity__empty { padding: 16px 24px; color: var(--text-2); }
31
+ .chips { display: flex; gap: 8px; padding: 12px 24px 4px; }
32
+ .chip { height: 32px; padding: 0 12px; border: 0; border-radius: 16px; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .6); background: #fff; font-weight: 600; color: var(--text-2); cursor: pointer; }
33
+ .chip:hover { box-shadow: inset 0 0 0 2px rgba(0, 0, 0, .75); background: var(--hover); }
34
+ .chip[aria-pressed="true"] { background: var(--green); color: #fff; box-shadow: none; }
35
+ .activity-list { padding: 0 24px; }
36
+ .activity-list > .spinner { margin: 16px auto; display: block; }
37
+ .activity-row { padding: 12px 0; border-bottom: 1px solid var(--divider); }
38
+ .activity-row__meta { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-2); }
39
+ .activity-row__text { margin-top: 4px; white-space: pre-wrap; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
40
+ .activity-row__actions { margin-top: 8px; }
41
+ .activity-more { display: flex; justify-content: center; padding: 8px; }
42
+ .badge { padding: 0 6px; border-radius: 4px; font-size: 12px; font-weight: 600; }
43
+ .badge--draft { background: #fcf0d2; color: #915907; }
44
+
45
+ .company-about { padding: 16px 24px; }
46
+ .company-about .section-head__title { margin-bottom: 8px; }
47
+ .company-about__text { white-space: pre-wrap; margin-bottom: 12px; }
48
+ .company-about__facts { margin: 0; }
49
+ .company-about__facts dt { font-weight: 600; margin-top: 12px; }
50
+ .company-about__facts dd { margin: 2px 0 0; color: var(--text-2); overflow-wrap: anywhere; }
51
+ .company-admins__row { display: flex; gap: 12px; align-items: center; padding: 8px 0; }
52
+
53
+ .network-layout { display: grid; grid-template-columns: 300px minmax(0, 804px); gap: 24px; justify-content: center; align-items: start; }
54
+ .network-manage { padding: 12px 0; }
55
+ .network-manage__title { padding: 0 16px 8px; font-size: 16px; font-weight: 600; }
56
+ .network-manage__item { display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px 16px; border: 0; background: none; font-size: 16px; font-weight: 600; color: var(--text-2); cursor: pointer; text-decoration: none !important; }
57
+ .network-manage__item:hover { background: var(--hover); color: var(--text); }
58
+ .network-manage__item--active { color: var(--green); box-shadow: inset 2px 0 0 var(--green); }
59
+ .network { padding-bottom: 8px; }
60
+ .network__count { padding: 16px 24px 8px; font-size: 20px; font-weight: 400; }
61
+ .network__toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 0 24px 8px; border-bottom: 1px solid var(--divider); }
62
+ .network__sort { display: flex; align-items: center; font-size: 14px; }
63
+ .network__sort-btn { display: inline-flex; align-items: center; gap: 2px; border: 0; background: none; font-weight: 600; color: var(--text-2); cursor: pointer; }
64
+ .network__sort-btn .icon { width: 16px; height: 16px; }
65
+ .network__search { position: relative; display: flex; align-items: center; }
66
+ .network__search > .icon { position: absolute; left: 8px; width: 16px; height: 16px; color: var(--text-2); }
67
+ .network__search-input { width: 240px; height: 32px; padding: 0 8px 0 32px; border: 1px solid rgba(0, 0, 0, .6); border-radius: 4px; }
68
+ .network__row { display: flex; align-items: center; gap: 16px; margin: 0 24px; padding: 16px 0; border-bottom: 1px solid var(--divider); }
69
+ .network__meta { display: flex; flex-direction: column; flex: 1; min-width: 0; }
70
+ .network__name { font-size: 16px; font-weight: 600; color: var(--text); }
71
+ .network__name:hover { color: var(--blue); }
72
+ .network__headline { font-size: 14px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
73
+ .network__date { margin-top: 4px; font-size: 12px; color: var(--text-2); }
74
+ .network__actions { display: flex; align-items: center; gap: 4px; }
75
+ .network__empty, .network__loading { padding: 32px 24px; text-align: center; }
76
+ .network__more { display: flex; justify-content: center; padding: 8px; }
77
+
78
+ @media (max-width: 991px) { .profile-layout, .network-layout { grid-template-columns: minmax(0, 1fr); } .profile-aside { display: none; } }
@@ -0,0 +1,83 @@
1
+ // Like (with the six-reaction picker), Comment, Repost and Send buttons of a post card.
2
+ import { el, call, newKey, describe, toast } from "./ui.js";
3
+ import { icon } from "./icons.js";
4
+ import { REACTIONS, REACTION_BY_TYPE, reactionBadge } from "./reactions.js";
5
+ import { session } from "./store.js";
6
+ import { toggleComments } from "./comments.js";
7
+ import { openComposer } from "./composer.js";
8
+ import { notSimulated } from "./overlay.js";
9
+
10
+ export function renderActions(host, entry, state, { paint, commentsHost, ctx }) {
11
+ const { post } = entry;
12
+ const likeWrap = el("div", { class: "social-actions__like" });
13
+ const likeButton = el("button", { class: "social-action", attrs: { type: "button", "aria-pressed": "false" } });
14
+ let picker = null;
15
+ let hoverTimer;
16
+ let pendingReaction = false;
17
+
18
+ const paintLike = () => {
19
+ const reaction = state.viewerReaction ? REACTION_BY_TYPE.get(state.viewerReaction) : null;
20
+ likeButton.replaceChildren(reaction && reaction.type !== "LIKE" ? reactionBadge(reaction.type, 20) : icon("like"), el("span", { text: reaction ? reaction.label : "Like" }));
21
+ likeButton.setAttribute("aria-pressed", String(Boolean(reaction)));
22
+ likeButton.setAttribute("aria-label", reaction ? `Undo ${reaction.label} reaction` : "React Like");
23
+ if (reaction) likeButton.dataset.reaction = reaction.type; else delete likeButton.dataset.reaction;
24
+ };
25
+
26
+ const react = async (type) => {
27
+ if (pendingReaction) return;
28
+ pendingReaction = true;
29
+ closePicker();
30
+ const actor = session.viewer.personUrn;
31
+ const previous = state.viewerReaction;
32
+ try {
33
+ if (type === null) await call("reactions.delete", { actor, entity: post.id }, newKey());
34
+ else await call("reactions.create", { actor, root: post.id, reactionType: type }, newKey());
35
+ state.viewerReaction = type;
36
+ paintLike();
37
+ await host.closest(".feed-post")?.refreshSocial?.();
38
+ } catch (error) {
39
+ state.viewerReaction = previous;
40
+ paintLike();
41
+ toast(describe(error), { error: true });
42
+ } finally {
43
+ pendingReaction = false;
44
+ }
45
+ };
46
+
47
+ const openPicker = () => {
48
+ if (picker) return;
49
+ picker = el("div", { class: "reaction-picker", attrs: { role: "toolbar", "aria-label": "Reactions" } }, REACTIONS.map((reaction) =>
50
+ el("button", { class: "reaction-picker__item", attrs: { type: "button", "aria-label": reaction.label }, on: { click: () => react(reaction.type) } }, [
51
+ reactionBadge(reaction.type, 36), el("span", { class: "reaction-picker__tip", text: reaction.label, attrs: { "aria-hidden": "true" } }),
52
+ ])));
53
+ picker.addEventListener("mouseenter", () => clearTimeout(hoverTimer));
54
+ picker.addEventListener("mouseleave", () => { hoverTimer = setTimeout(closePicker, 300); });
55
+ picker.addEventListener("keydown", (event) => { if (event.key === "Escape") { closePicker(); likeButton.focus(); } });
56
+ likeWrap.append(picker);
57
+ };
58
+ const closePicker = () => { picker?.remove(); picker = null; };
59
+
60
+ likeButton.addEventListener("click", () => react(state.viewerReaction ? null : "LIKE"));
61
+ likeButton.addEventListener("mouseenter", () => { clearTimeout(hoverTimer); hoverTimer = setTimeout(openPicker, 500); });
62
+ likeButton.addEventListener("mouseleave", () => { clearTimeout(hoverTimer); hoverTimer = setTimeout(closePicker, 400); });
63
+ likeButton.addEventListener("keydown", (event) => {
64
+ if (event.key === "ArrowUp" || (event.key === "Enter" && event.shiftKey)) { event.preventDefault(); openPicker(); picker.querySelector("button").focus(); }
65
+ });
66
+ paintLike();
67
+ likeWrap.append(likeButton);
68
+
69
+ const commentButton = el("button", { class: "social-action", attrs: { type: "button", "aria-label": "Comment" }, on: {
70
+ click: () => toggleComments(commentsHost, entry, { focus: true, onChanged: () => host.closest(".feed-post")?.refreshSocial?.() }),
71
+ } }, [icon("comment"), el("span", { text: "Comment" })]);
72
+
73
+ const reshareDisabled = post.isReshareDisabledByAuthor || post.visibility !== "PUBLIC";
74
+ const repostTarget = entry.reshareOf && !post.commentary ? entry.reshareOf : entry;
75
+ const repostButton = el("button", { class: "social-action", title: reshareDisabled ? "The author has disabled reposting for this post" : "Repost", attrs: { type: "button", "aria-label": "Repost", disabled: reshareDisabled ? true : undefined }, on: {
76
+ click: () => openComposer({ repostOf: repostTarget, onDone: ctx.onReload }),
77
+ } }, [icon("repost"), el("span", { text: "Repost" })]);
78
+
79
+ const sendButton = el("button", { class: "social-action", attrs: { type: "button", "aria-label": "Send in a private message" }, on: { click: () => notSimulated("Sending posts in messages") } }, [icon("send"), el("span", { text: "Send" })]);
80
+
81
+ host.append(likeWrap, el("div", {}, commentButton), el("div", {}, repostButton), el("div", {}, sendButton));
82
+ void paint;
83
+ }
@@ -0,0 +1,72 @@
1
+ // The "…" control menu on a post: edit, delete, comments on/off, copy link, and not-simulated entries.
2
+ import { call, newKey, describe, toast } from "./ui.js";
3
+ import { confirmDialog, notSimulated } from "./overlay.js";
4
+ import { openComposer } from "./composer.js";
5
+ import { canManage } from "./post.js";
6
+
7
+ export function postMenu(entry, ctx) {
8
+ const { post } = entry;
9
+ const items = [
10
+ { icon: "bookmark", label: "Save", sub: "Save for later", onSelect: () => notSimulated("Saved items") },
11
+ { icon: "link", label: "Copy link to post", onSelect: () => copyLink(post.id) },
12
+ ];
13
+ if (canManage(post.author)) {
14
+ items.push(
15
+ { icon: "pencil", label: "Edit post", onSelect: () => openComposer({ edit: entry, onDone: ctx.onReload }) },
16
+ { icon: "trash", label: "Delete post", onSelect: () => deletePost(post, ctx) },
17
+ );
18
+ const closed = entry.social.commentsState === "CLOSED";
19
+ if (post.author === entry.actor.urn) {
20
+ items.push({
21
+ icon: closed ? "comment" : "comment-off",
22
+ label: closed ? "Turn on commenting" : "Turn off commenting",
23
+ sub: closed ? "Let people comment on this post" : "Existing comments will be deleted",
24
+ onSelect: () => setCommentsState(entry, closed ? "OPEN" : "CLOSED", ctx),
25
+ });
26
+ }
27
+ } else {
28
+ items.push({ icon: "close", label: "Not interested", sub: "See fewer posts like this", onSelect: () => notSimulated("Feed preferences") });
29
+ }
30
+ return items;
31
+ }
32
+
33
+ async function copyLink(urn) {
34
+ const text = `https://www.linkedin.com/feed/update/${urn}/`;
35
+ try {
36
+ await navigator.clipboard.writeText(text);
37
+ toast("Link copied to clipboard.");
38
+ } catch {
39
+ toast(`Copy this link: ${text}`);
40
+ }
41
+ }
42
+
43
+ async function deletePost(post, ctx) {
44
+ const ok = await confirmDialog({ title: "Delete post?", message: "Are you sure you want to permanently remove this post from LinkedIn?", confirmLabel: "Delete", danger: true });
45
+ if (!ok) return;
46
+ try {
47
+ await call("posts.delete", { postUrn: post.id }, newKey());
48
+ toast("Post deleted.");
49
+ await ctx.onReload();
50
+ } catch (error) {
51
+ toast(describe(error), { error: true });
52
+ }
53
+ }
54
+
55
+ async function setCommentsState(entry, next, ctx) {
56
+ if (next === "CLOSED") {
57
+ const count = entry.social.commentSummary?.count ?? 0;
58
+ const ok = await confirmDialog({
59
+ title: "Turn off commenting?",
60
+ message: count > 0 ? `This will delete the ${count} existing comment${count === 1 ? "" : "s"} on this post. People won't be able to comment.` : "People won't be able to comment on this post.",
61
+ confirmLabel: "Turn off",
62
+ });
63
+ if (!ok) return;
64
+ }
65
+ try {
66
+ await call("social_metadata.set_comments_state", { entity: entry.post.id, actor: entry.post.author, commentsState: next }, newKey());
67
+ toast(next === "CLOSED" ? "Commenting turned off." : "Commenting turned on.");
68
+ await ctx.onReload();
69
+ } catch (error) {
70
+ toast(describe(error), { error: true });
71
+ }
72
+ }
@@ -0,0 +1,82 @@
1
+ .feed-post { position: relative; padding-top: 12px; }
2
+ .feed-post__controls { position: absolute; top: 4px; right: 8px; display: flex; }
3
+ .feed-post__controls .icon-btn { width: 32px; height: 32px; }
4
+ .feed-post__controls .icon { width: 24px; height: 24px; }
5
+
6
+ .post-actor { display: flex; gap: 8px; padding: 0 16px; margin-right: 72px; margin-bottom: 8px; }
7
+ .post-actor__avatar { flex: none; }
8
+ .post-actor__meta { display: flex; flex-direction: column; min-width: 0; }
9
+ .post-actor__name-line { font-size: 14px; line-height: 20px; }
10
+ .post-actor__name { font-weight: 600; color: var(--text); }
11
+ .post-actor__name:hover { color: var(--blue); text-decoration: underline; }
12
+ .post-actor__degree { color: var(--text-2); font-weight: 400; }
13
+ .post-actor__headline { font-size: 12px; line-height: 16px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
14
+ .post-actor__time { display: block; white-space: pre; font-size: 12px; line-height: 16px; color: var(--text-2); }
15
+ .post-actor__vis { display: inline-block; vertical-align: -3px; }
16
+ .post-actor__vis .icon { width: 16px; height: 16px; }
17
+ .post-actor--compact { padding: 0; margin: 0 0 8px; }
18
+
19
+ .post-text { position: relative; padding: 0 16px; margin-bottom: 8px; font-size: 14px; line-height: 20px; color: var(--text); }
20
+ .post-text__inner { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; white-space: pre-wrap; overflow-wrap: anywhere; }
21
+ .post-text--expanded .post-text__inner { display: block; -webkit-line-clamp: unset; }
22
+ .post-text__more { position: absolute; right: 16px; bottom: 0; padding: 0 0 0 24px; border: 0; background: linear-gradient(90deg, rgba(255,255,255,0), #fff 24px); color: var(--text-2); font-size: 14px; line-height: 20px; cursor: pointer; }
23
+ .post-text__more:hover { color: var(--blue); text-decoration: underline; }
24
+ .hashtag, .mention { font-weight: 600; color: var(--blue); }
25
+
26
+ .post-article { display: flex; align-items: stretch; margin: 4px 0 0; background: #eef3f8; color: var(--text); text-decoration: none !important; }
27
+ .post-article__art { display: flex; align-items: center; justify-content: center; flex: none; width: 138px; min-height: 72px; background: #dce6f1; color: #56687a; }
28
+ .post-article__meta { display: flex; flex-direction: column; justify-content: center; padding: 8px 12px; min-width: 0; }
29
+ .post-article__title { font-weight: 600; font-size: 14px; line-height: 20px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
30
+ .post-article__source { font-size: 12px; color: var(--text-2); }
31
+ .post-article:hover .post-article__title { color: var(--blue); text-decoration: underline; }
32
+
33
+ .post-reshare { margin: 4px 16px 8px; padding: 12px 0 4px; border: 1px solid #e0dfdc; border-radius: 8px; }
34
+ .post-reshare .post-actor { padding: 0 12px; }
35
+ .post-reshare .post-text { padding: 0 12px; }
36
+ .post-reshare .post-article { margin: 0 0 8px; }
37
+ .post-reshare--gone { padding: 16px; color: var(--text-2); }
38
+
39
+ .social-counts { display: flex; align-items: center; justify-content: space-between; margin: 0 16px; padding: 8px 0; border-bottom: 1px solid var(--divider); font-size: 12px; color: var(--text-2); }
40
+ .social-counts__reactions { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: none; font-size: 12px; color: var(--text-2); cursor: pointer; }
41
+ .social-counts__reactions:hover { color: var(--blue); text-decoration: underline; }
42
+ .social-counts__badges { display: inline-flex; }
43
+ .social-counts__badges .reaction-badge { margin-right: -4px; border: 1px solid #fff; border-radius: 50%; box-sizing: content-box; }
44
+ .social-counts__badges .reaction-badge:last-child { margin-right: 2px; }
45
+ .social-counts__right { margin-left: auto; display: inline-flex; gap: 4px; }
46
+ .social-counts__link { padding: 0; border: 0; background: none; font-size: 12px; color: var(--text-2); cursor: pointer; }
47
+ .social-counts__link:hover { color: var(--blue); text-decoration: underline; }
48
+ .social-counts__link--static:hover { color: var(--text-2); text-decoration: none; cursor: default; }
49
+
50
+ .social-actions { display: flex; justify-content: space-around; padding: 4px 8px; }
51
+ .social-actions > * { flex: 1; display: flex; justify-content: center; position: relative; }
52
+ .social-action { display: inline-flex; align-items: center; justify-content: center; gap: 4px; width: 100%; min-height: 48px; padding: 10px 8px; border: 0; border-radius: 4px; background: none; font-size: 14px; font-weight: 600; color: var(--text-2); cursor: pointer; }
53
+ .social-action:hover { background: var(--hover); color: var(--text); }
54
+ .social-action:disabled { color: rgba(0, 0, 0, 0.3); cursor: not-allowed; background: none; }
55
+ .social-action .icon { width: 20px; height: 20px; }
56
+ .social-action .icon--repost { transform: scale(.85); }
57
+ .social-action .reaction-badge { width: 20px; height: 20px; }
58
+ .social-action[data-reaction="LIKE"] { color: #378fe9; }
59
+ .social-action[data-reaction="PRAISE"] { color: #44712e; }
60
+ .social-action[data-reaction="APPRECIATION"] { color: #715e86; }
61
+ .social-action[data-reaction="EMPATHY"] { color: #b24020; }
62
+ .social-action[data-reaction="INTEREST"] { color: #915907; }
63
+ .social-action[data-reaction="ENTERTAINMENT"] { color: #1a707e; }
64
+ .social-action[data-reaction="LIKE"] .icon--like { transform: scaleX(-1) rotate(0deg); }
65
+
66
+ .reaction-picker { position: absolute; bottom: 46px; left: 0; display: flex; gap: 2px; padding: 4px 6px; background: #fff; border-radius: 24px; box-shadow: 0 0 0 1px var(--hairline), 0 4px 12px rgba(0, 0, 0, .15); z-index: 10; animation: pop .12s ease-out; }
67
+ .reaction-picker__item { position: relative; display: flex; padding: 2px; border: 0; background: none; border-radius: 50%; cursor: pointer; transition: transform .1s; }
68
+ .reaction-picker__item:hover, .reaction-picker__item:focus-visible { transform: translateY(-6px) scale(1.25); }
69
+ .reaction-picker__item .reaction-badge { width: 36px; height: 36px; }
70
+ .reaction-picker__tip { position: absolute; bottom: 48px; left: 50%; transform: translateX(-50%); padding: 2px 8px; border-radius: 12px; background: #1d2226; color: #fff; font-size: 12px; font-weight: 600; white-space: nowrap; opacity: 0; pointer-events: none; }
71
+ .reaction-picker__item:hover .reaction-picker__tip, .reaction-picker__item:focus-visible .reaction-picker__tip { opacity: 1; }
72
+ @keyframes pop { from { transform: translateY(6px); opacity: 0; } to { transform: none; opacity: 1; } }
73
+
74
+ .dropdown { position: absolute; top: 36px; min-width: 264px; padding: 8px 0; background: #fff; border-radius: 8px 0 8px 8px; box-shadow: 0 0 0 1px var(--hairline), 0 4px 12px rgba(0, 0, 0, .15); z-index: 15; }
75
+ .dropdown--right { right: 0; }
76
+ .dropdown--left { left: 0; border-radius: 0 8px 8px 8px; }
77
+ .dropdown__item { display: flex; align-items: flex-start; gap: 12px; width: 100%; padding: 12px 16px; border: 0; background: none; text-align: left; color: var(--text-2); cursor: pointer; }
78
+ .dropdown__item:hover, .dropdown__item:focus-visible { background: var(--hover); color: var(--text); outline: none; }
79
+ .dropdown__item:disabled { opacity: .5; cursor: not-allowed; }
80
+ .dropdown__text { display: flex; flex-direction: column; }
81
+ .dropdown__label { font-size: 14px; font-weight: 600; color: var(--text); }
82
+ .dropdown__sub { font-size: 12px; color: var(--text-2); }