@kodaris/krubble-app-components 1.0.106 → 1.0.108

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.
@@ -1,4 +1,4 @@
1
- import { KRContextMenu, KRDialog, KRFilePreview } from '@kodaris/krubble-components';
1
+ import { KRContextMenu, KRDialog, KRSnackbar, KRFilePreview } from '@kodaris/krubble-components';
2
2
  import { KRHttp } from '@kodaris/krubble-http';
3
3
 
4
4
  /**
@@ -5775,10 +5775,10 @@ let KRChatbot = class KRChatbot extends i$3 {
5775
5775
  */
5776
5776
  this._globalSkills = [];
5777
5777
  /**
5778
- * Latest results for the `/` composer menu: this instance's own org skills PLUS
5779
- * the global skills installed on it (both are invocable by code). A separate
5780
- * buffer from the two browse-tab lists so merging installed globals into the
5781
- * menu never leaks them into the "Your Org" tab.
5778
+ * Latest results for the `/` composer menu — everything the current user can
5779
+ * actually invoke, merged and role-filtered server-side by
5780
+ * `/conversation/availableSkills`. A separate buffer from the two browse-tab lists
5781
+ * so installed globals in the menu never leak into the "Your Org" tab.
5782
5782
  */
5783
5783
  this._invocableSkills = [];
5784
5784
  /**
@@ -6356,77 +6356,30 @@ let KRChatbot = class KRChatbot extends i$3 {
6356
6356
  }
6357
6357
  // --- Skills ---
6358
6358
  /**
6359
- * Search the Kodaris AI skills index and store the results in `_orgSkills`.
6360
- * The `/` menu and the browse dialog both call this; results are shared since
6361
- * the two are never open at once. Mirrors the backend's own skill search (BM25
6362
- * over name^3/description^2/instructions, active-only) so the menu ranks skills
6363
- * the way the model does. An empty query uses match_all — multi_match with an
6364
- * empty string matches nothing, so the "show everything" case needs it.
6359
+ * Load the browse dialog's "Your Org" tab into `_orgSkills` from
6360
+ * `/conversation/localSkillList` — this instance's own active skills, already
6361
+ * role-filtered to what the caller may run and ranked by the server.
6365
6362
  *
6366
- * Follows the kr-combo-box `_fetch` pattern: a request-id guard drops responses
6367
- * that arrive out of order. `/aiskills/search` returns a raw OpenSearch response,
6368
- * so we lift each hit's `_source` (already in KRChatbotSkill shape) out.
6363
+ * Not the `aiskills` search endpoint: that requires a skill-authoring role
6364
+ * (`skillView`/`skillEdit`), so an ordinary chat user got a 403 and an empty
6365
+ * tab. This one only needs a logged-in employee. It also means the query
6366
+ * matching, active-only filter and "exclude globally-published" rule all live
6367
+ * server-side instead of being rebuilt as OpenSearch DSL here.
6368
+ *
6369
+ * EXCEPTION — on kodaris.com this list also carries that instance's own
6370
+ * globally-published skills (flagged `global: true`), because there is no
6371
+ * Kodaris tab there for them to appear on.
6372
+ *
6373
+ * Request-id guard drops responses that arrive out of order (kr-combo-box
6374
+ * `_fetch` pattern).
6369
6375
  */
6370
6376
  _searchOrgSkills(query) {
6371
6377
  this._orgSkillRequestId++;
6372
6378
  const requestId = this._orgSkillRequestId;
6373
- const trimmed = query.trim();
6374
- // An empty query matches everything; otherwise substring-match the query
6375
- // against name/code/description. We use case-insensitive wildcard queries on
6376
- // the `.raw` keyword subfields so "ord" matches anywhere in "Create Order"
6377
- // (a plain match/multi_match only matches whole tokens). Escape the wildcard
6378
- // specials so user input is treated literally, then wrap with `*...*`.
6379
- let must = { match_all: {} };
6380
- if (trimmed) {
6381
- must = {
6382
- bool: {
6383
- should: [
6384
- {
6385
- wildcard: {
6386
- 'name.raw': {
6387
- value: `*${trimmed.replace(/([*?\\])/g, '\\$1')}*`,
6388
- case_insensitive: true,
6389
- }
6390
- }
6391
- },
6392
- {
6393
- wildcard: {
6394
- 'code.raw': {
6395
- value: `*${trimmed.replace(/([*?\\])/g, '\\$1')}*`,
6396
- case_insensitive: true,
6397
- }
6398
- }
6399
- },
6400
- {
6401
- wildcard: {
6402
- 'description.raw': {
6403
- value: `*${trimmed.replace(/([*?\\])/g, '\\$1')}*`,
6404
- case_insensitive: true,
6405
- }
6406
- }
6407
- },
6408
- ],
6409
- minimum_should_match: 1,
6410
- },
6411
- };
6412
- }
6413
6379
  KRHttp.fetch({
6414
- url: '/api/system/opensearch/aiskills/search',
6380
+ url: '/api/system/conversation/localSkillList',
6415
6381
  method: 'POST',
6416
- body: {
6417
- size: 100,
6418
- query: {
6419
- bool: {
6420
- must: [must],
6421
- filter: [{ term: { active: true } }],
6422
- // The "Your Org" tab lists only this instance's own skills. Globally-
6423
- // published skills belong to the "Kodaris" tab (fed by the registry),
6424
- // so exclude them here. Documents without the field aren't matched by
6425
- // the term, so ordinary org skills are kept.
6426
- must_not: [{ term: { globalPublished: true } }],
6427
- },
6428
- },
6429
- },
6382
+ body: { query: query.trim() },
6430
6383
  })
6431
6384
  .then((r) => r.json())
6432
6385
  .then((res) => {
@@ -6434,114 +6387,63 @@ let KRChatbot = class KRChatbot extends i$3 {
6434
6387
  if (requestId !== this._orgSkillRequestId) {
6435
6388
  return;
6436
6389
  }
6437
- this._orgSkills = (res?.data?.hits?.hits || []).map((hit) => hit._source);
6390
+ this._orgSkills = (res?.data || []);
6438
6391
  })
6439
6392
  .catch((error) => {
6440
6393
  console.error('kr-chatbot: skill search failed', error);
6441
6394
  });
6442
6395
  }
6443
6396
  /**
6444
- * Search skills for the `/` composer menu into `_invocableSkills`: this instance's
6445
- * own org skills PLUS the global skills installed on it, since both are
6446
- * invocable by code. They come from two indexes (the local aiskills search and
6447
- * the registry search filtered client-side to enabled), so we fire both and
6448
- * concat — org skills first, then installed globals. Skill codes are unique
6449
- * across the two, so no dedup is needed. Kept out of `_orgSkills` so
6450
- * installed globals never leak into the browse dialog's "Your Org" tab.
6451
- * (Cross-list relevance ranking is a later concern; for now org skills lead.)
6397
+ * Load the skills for the `/` composer menu into `_invocableSkills` from
6398
+ * `/conversation/availableSkills` — this instance's own skills plus the installed
6399
+ * global ones, already merged, role-filtered and ranked by the server. That's the
6400
+ * same set the model sees via `list_skills`, so the menu can't offer a skill the
6401
+ * chat would then refuse.
6402
+ *
6403
+ * Not the `aiskills` endpoints the browse dialog's Org tab uses: those require a
6404
+ * skill-authoring role, which an ordinary chat user doesn't have.
6405
+ *
6406
+ * Request-id guard drops responses that arrive out of order (kr-combo-box `_fetch`
6407
+ * pattern).
6452
6408
  */
6453
6409
  _searchInvocableSkills(query) {
6454
6410
  this._invocableSkillRequestId++;
6455
6411
  const requestId = this._invocableSkillRequestId;
6456
- // Same wildcard matching as _searchOrgSkills (see there for the rationale).
6457
- let must = { match_all: {} };
6458
- if (query.trim()) {
6459
- must = {
6460
- bool: {
6461
- should: [
6462
- {
6463
- wildcard: {
6464
- 'name.raw': {
6465
- value: `*${query.trim().replace(/([*?\\])/g, '\\$1')}*`,
6466
- case_insensitive: true,
6467
- }
6468
- }
6469
- },
6470
- {
6471
- wildcard: {
6472
- 'code.raw': {
6473
- value: `*${query.trim().replace(/([*?\\])/g, '\\$1')}*`,
6474
- case_insensitive: true,
6475
- }
6476
- }
6477
- },
6478
- {
6479
- wildcard: {
6480
- 'description.raw': {
6481
- value: `*${query.trim().replace(/([*?\\])/g, '\\$1')}*`,
6482
- case_insensitive: true,
6483
- }
6484
- }
6485
- },
6486
- ],
6487
- minimum_should_match: 1,
6488
- },
6489
- };
6490
- }
6491
- // Org skills — this instance's own catalog (globalPublished excluded).
6492
- const orgSearch = KRHttp.fetch({
6493
- url: '/api/system/opensearch/aiskills/search',
6494
- method: 'POST',
6495
- body: {
6496
- size: 100,
6497
- query: {
6498
- bool: {
6499
- must: [must],
6500
- filter: [{ term: { active: true } }],
6501
- must_not: [{ term: { globalPublished: true } }],
6502
- },
6503
- },
6504
- },
6505
- })
6506
- .then((r) => r.json())
6507
- .then((res) => (res?.data?.hits?.hits || []).map((hit) => hit._source))
6508
- .catch((error) => {
6509
- console.error('kr-chatbot: invocable skill search failed', error);
6510
- return [];
6511
- });
6512
- // Installed globals — the registry catalog, kept only where enabled=true.
6513
- const globalSearch = KRHttp.fetch({
6514
- url: '/api/system/opensearch/aiskills/global/search',
6412
+ KRHttp.fetch({
6413
+ url: '/api/system/conversation/availableSkills',
6515
6414
  method: 'POST',
6516
6415
  body: { query: query.trim() },
6517
6416
  })
6518
6417
  .then((r) => r.json())
6519
- .then((res) => (res?.data || []).filter((s) => !!s.enabled))
6520
- .catch((error) => {
6521
- console.error('kr-chatbot: installed global skill search failed', error);
6522
- return [];
6523
- });
6524
- Promise.all([orgSearch, globalSearch]).then(([orgSkills, globalSkills]) => {
6418
+ .then((res) => {
6525
6419
  // Ignore a stale response superseded by a newer search.
6526
6420
  if (requestId !== this._invocableSkillRequestId) {
6527
6421
  return;
6528
6422
  }
6529
- this._invocableSkills = [...orgSkills, ...globalSkills];
6423
+ this._invocableSkills = (res?.data || []);
6424
+ })
6425
+ .catch((error) => {
6426
+ console.error('kr-chatbot: invocable skill search failed', error);
6530
6427
  });
6531
6428
  }
6532
6429
  /**
6533
- * Search the shared Kodaris global-skill registry into `_globalSkills`
6534
- * (the browse dialog's "Kodaris" tab). Unlike `/aiskills/search`,
6535
- * `/aiskills/global/search` returns a digested list
6536
- * (`data: [{ code, name, description, enabled }]`) already in `KRChatbotSkill`
6537
- * shape, so no `_source` lifting is needed. Only the browse dialog calls this
6538
- * (the `/` menu is org-only), so it isn't run per slash keystroke.
6430
+ * Load the browse dialog's "Kodaris" tab into `_globalSkills` — the whole
6431
+ * published Kodaris catalog for discovery, installed or not, runnable by this
6432
+ * user or not. Each row carries `enabled` (installed here), `canEnable` (may
6433
+ * install it) and `canRun` (roles allow invoking it).
6434
+ *
6435
+ * Uses `/conversation/globalSkillList` rather than the `/aiskills/global/search`
6436
+ * twin, which requires a skill-authoring role an ordinary chat user doesn't have.
6437
+ *
6438
+ * Returns `[]` — not an error — when there's no reachable registry, and always
6439
+ * on kodaris.com, which IS the catalog source and so has no remote to pull. The
6440
+ * tab itself is hidden there (see `_renderSkillBrowse`).
6539
6441
  */
6540
6442
  _searchGlobalSkills(query) {
6541
6443
  this._globalSkillRequestId++;
6542
6444
  const requestId = this._globalSkillRequestId;
6543
6445
  KRHttp.fetch({
6544
- url: '/api/system/opensearch/aiskills/global/search',
6446
+ url: '/api/system/conversation/globalSkillList',
6545
6447
  method: 'POST',
6546
6448
  body: { query: query.trim() },
6547
6449
  })
@@ -6565,6 +6467,17 @@ let KRChatbot = class KRChatbot extends i$3 {
6565
6467
  _handleGlobalSkillEnable(skill, e) {
6566
6468
  // The card body inserts the skill; the install button must not also do that.
6567
6469
  e.stopPropagation();
6470
+ // The button is shown to everyone so the catalog reads the same for all users,
6471
+ // but the enable endpoint requires administrator/superuser/skillEdit. Without
6472
+ // it, say who to ask instead of firing a request that comes back 403.
6473
+ if (!skill.canEnable) {
6474
+ KRSnackbar.show({
6475
+ title: 'Permission Required',
6476
+ message: 'You do not have permission to install skills. Please contact your site administrator.',
6477
+ type: 'error',
6478
+ });
6479
+ return;
6480
+ }
6568
6481
  if (this._globalSkillPending.has(skill.code)) {
6569
6482
  return;
6570
6483
  }
@@ -6691,13 +6604,33 @@ let KRChatbot = class KRChatbot extends i$3 {
6691
6604
  recognition.start();
6692
6605
  this._inputEl?.focus();
6693
6606
  }
6607
+ /**
6694
6608
  /** Insert `/code ` into the composer and focus it, ready for the message. */
6695
6609
  _handleSkillSelect(skill) {
6696
- // A global skill must be installed before it can be chosen — inserting /code
6697
- // for one not enabled on this instance would just fail. `enabled` is only set
6698
- // on global skills (false = not installed); org skills leave it undefined and
6699
- // are always selectable. So an uninstalled global card click does nothing.
6700
- if (skill.enabled === false) {
6610
+ // The user's roles don't allow this skill — inserting /code would just fail in the
6611
+ // chat. Name the roles that would grant it, so the user knows what to ask for and
6612
+ // whoever grants it knows what to give.
6613
+ if (skill.canRun === false) {
6614
+ let message = `You do not have access to "${skill.name}". Ask your site administrator to grant it.`;
6615
+ if (skill.allowedRoles?.length) {
6616
+ message = `"${skill.name}" requires the ${skill.allowedRoles.join(' or ')} role. Ask your site administrator to grant it.`;
6617
+ }
6618
+ KRSnackbar.show({
6619
+ title: 'Access Required',
6620
+ message: message,
6621
+ type: 'error',
6622
+ });
6623
+ return;
6624
+ }
6625
+ // A global skill nobody has installed here yet isn't runnable either — point at the
6626
+ // Install button rather than appearing to ignore the click. Local skills carry no
6627
+ // install state, so this only applies to global ones.
6628
+ if (skill.global && !skill.enabled) {
6629
+ KRSnackbar.show({
6630
+ title: 'Not Installed',
6631
+ message: `"${skill.name}" has to be installed on this site before you can use it — use the Install button on the card.`,
6632
+ type: 'info',
6633
+ });
6701
6634
  return;
6702
6635
  }
6703
6636
  this._inputValue = `/${skill.code} `;
@@ -6729,8 +6662,11 @@ let KRChatbot = class KRChatbot extends i$3 {
6729
6662
  this._skillBrowseQuery = '';
6730
6663
  this._skillBrowseOpened = true;
6731
6664
  // Fill both tabs up front so switching between them is instant, not stale.
6665
+ // The Kodaris tab isn't rendered on the registry server, so skip its fetch there.
6732
6666
  this._searchOrgSkills('');
6733
- this._searchGlobalSkills('');
6667
+ if (this._aiFrameworkConfig.registryServer !== true) {
6668
+ this._searchGlobalSkills('');
6669
+ }
6734
6670
  }
6735
6671
  _handleSkillBrowseClose() {
6736
6672
  this._skillBrowseOpened = false;
@@ -6739,7 +6675,9 @@ let KRChatbot = class KRChatbot extends i$3 {
6739
6675
  this._skillBrowseQuery = e.target.value;
6740
6676
  // Filter both catalogs so whichever tab the user switches to is already current.
6741
6677
  this._searchOrgSkills(this._skillBrowseQuery);
6742
- this._searchGlobalSkills(this._skillBrowseQuery);
6678
+ if (this._aiFrameworkConfig.registryServer !== true) {
6679
+ this._searchGlobalSkills(this._skillBrowseQuery);
6680
+ }
6743
6681
  }
6744
6682
  _handleSkillBrowseTabChange(e) {
6745
6683
  // Both buffers are kept current by open/search, so switching just swaps which
@@ -7081,6 +7019,15 @@ let KRChatbot = class KRChatbot extends i$3 {
7081
7019
  userText: params.message,
7082
7020
  fileIds: files.map((f) => f.conversationFileID),
7083
7021
  };
7022
+ // Ground the model in where the user is and what they've highlighted.
7023
+ // Only sent when present so an empty selection / failed capture doesn't
7024
+ // add noise to the request.
7025
+ if (params.pageContext) {
7026
+ body.pageContext = params.pageContext;
7027
+ }
7028
+ if (params.selectionContext) {
7029
+ body.selectionContext = params.selectionContext;
7030
+ }
7084
7031
  if (this.conversationId != null) {
7085
7032
  body.conversationID = this.conversationId;
7086
7033
  }
@@ -7125,10 +7072,16 @@ let KRChatbot = class KRChatbot extends i$3 {
7125
7072
  });
7126
7073
  };
7127
7074
  }
7075
+ // Capture where the user is and what they've highlighted at send time (not
7076
+ // earlier — selection is transient and the route may have changed since the
7077
+ // panel opened), so every turn grounds the model in the user's current spot.
7078
+ const context = this._captureContext();
7128
7079
  this.send({
7129
7080
  message: params.message,
7130
7081
  messages: this.messages.slice(0, -1),
7131
7082
  conversationFiles: params.conversationFiles,
7083
+ pageContext: context.pageContext,
7084
+ selectionContext: context.selectionContext,
7132
7085
  }).then((response) => {
7133
7086
  if (!response.ok) {
7134
7087
  this._handleStreamError('Request failed: ' + response.status);
@@ -7148,6 +7101,56 @@ let KRChatbot = class KRChatbot extends i$3 {
7148
7101
  }
7149
7102
  });
7150
7103
  }
7104
+ /**
7105
+ * Capture ambient "where is the user / what are they looking at" context at
7106
+ * send time, so the AI is grounded without the host passing anything in.
7107
+ *
7108
+ * Two universal sources that exist on every page the chatbot mounts in:
7109
+ * - `pageContext` — the location (full url, which already carries the path and
7110
+ * query) and title, plus, when the page renders the server-side
7111
+ * `#kodaris-page` JSON island (storefront content pages do), its parsed
7112
+ * contents (page name/type/ids). A page without the island still yields url
7113
+ * + title, so context is never empty on a real page.
7114
+ * - `selectionContext` — the text the user currently has highlighted, read
7115
+ * live here because a selection is transient (it may be gone a moment later).
7116
+ *
7117
+ * Both are returned as strings because that's what the backend `ModelForText`
7118
+ * fields (`pageContext` / `selectionContext`) accept. Capture is best-effort:
7119
+ * any failure yields an empty string for that field rather than blocking send.
7120
+ */
7121
+ _captureContext() {
7122
+ let pageContext = '';
7123
+ try {
7124
+ const page = {
7125
+ url: window.location.href,
7126
+ title: document.title,
7127
+ };
7128
+ // Structured page context is opt-in by convention: a page that renders the
7129
+ // `#kodaris-page` island gets its name/type/ids folded in for free. The
7130
+ // island's text is JSON; a malformed/absent island just leaves it out.
7131
+ const island = document.getElementById('kodaris-page');
7132
+ if (island && island.textContent) {
7133
+ try {
7134
+ page.page = JSON.parse(island.textContent);
7135
+ }
7136
+ catch { /* island present but not valid JSON — location context still stands */ }
7137
+ }
7138
+ pageContext = JSON.stringify(page);
7139
+ }
7140
+ catch { /* leave pageContext empty — never block a send on context capture */ }
7141
+ let selectionContext = '';
7142
+ try {
7143
+ const selection = window.getSelection();
7144
+ if (selection) {
7145
+ selectionContext = selection.toString().trim();
7146
+ }
7147
+ }
7148
+ catch { /* leave selectionContext empty */ }
7149
+ return {
7150
+ pageContext,
7151
+ selectionContext,
7152
+ };
7153
+ }
7151
7154
  _handleStop() {
7152
7155
  if (this._reader) {
7153
7156
  this._reader.cancel();
@@ -7421,7 +7424,14 @@ let KRChatbot = class KRChatbot extends i$3 {
7421
7424
  </div>
7422
7425
  `;
7423
7426
  }
7424
- /** The "Browse all skills" dialog — a searchable grid of Org / Kodaris cards. */
7427
+ /**
7428
+ * The "Browse all skills" dialog — a searchable grid of Org / Kodaris cards.
7429
+ *
7430
+ * The Kodaris tab is omitted on the registry server (kodaris.com): that instance
7431
+ * IS the catalog source, so `globalSkillList` has no remote to pull and always
7432
+ * returns `[]`. Its own published skills appear on the Your Org tab instead,
7433
+ * carrying a "Global" badge.
7434
+ */
7425
7435
  _renderSkillBrowse() {
7426
7436
  return b `
7427
7437
  <kr-dialog
@@ -7443,9 +7453,11 @@ let KRChatbot = class KRChatbot extends i$3 {
7443
7453
  <kr-tab id="org" label="Your Org">
7444
7454
  ${this._renderSkillGrid()}
7445
7455
  </kr-tab>
7446
- <kr-tab id="kodaris" label="Kodaris">
7447
- ${this._renderSkillGrid(true)}
7448
- </kr-tab>
7456
+ ${this._aiFrameworkConfig.registryServer === true ? A : b `
7457
+ <kr-tab id="kodaris" label="Kodaris">
7458
+ ${this._renderSkillGrid(true)}
7459
+ </kr-tab>
7460
+ `}
7449
7461
  </kr-tab-group>
7450
7462
  </div>
7451
7463
  </kr-dialog>
@@ -7454,8 +7466,13 @@ let KRChatbot = class KRChatbot extends i$3 {
7454
7466
  /**
7455
7467
  * The card grid of skill search results for the browse dialog. Clicking a card
7456
7468
  * inserts `/code ` into the composer. In the shared-Kodaris (`global`) tab each
7457
- * card also gets a footer row with an Enabled toggle that installs/removes the
7458
- * skill on this instance.
7469
+ * card also gets a footer row with an Install/Uninstall button.
7470
+ *
7471
+ * The Kodaris tab is the whole published catalog, deliberately NOT role-filtered so
7472
+ * users can discover skills they don't have yet. So a card there can be in one of two
7473
+ * non-runnable states — the user's roles don't allow it (`canRun: false`), or it isn't
7474
+ * installed here (not `enabled`). Neither looks clickable; clicking either explains
7475
+ * why via `_handleSkillSelect`.
7459
7476
  */
7460
7477
  _renderSkillGrid(global = false) {
7461
7478
  let skills = this._orgSkills;
@@ -7469,7 +7486,10 @@ let KRChatbot = class KRChatbot extends i$3 {
7469
7486
  <div class="skill-browse__grid">
7470
7487
  ${skills.map((skill) => b `
7471
7488
  <div
7472
- class="skill-card"
7489
+ class=${e({
7490
+ 'skill-card': true,
7491
+ 'skill-card--unavailable': skill.canRun === false || (!!skill.global && !skill.enabled),
7492
+ })}
7473
7493
  role="button"
7474
7494
  tabindex="0"
7475
7495
  @click=${() => this._handleSkillSelect(skill)}
@@ -7479,6 +7499,9 @@ let KRChatbot = class KRChatbot extends i$3 {
7479
7499
  ${skill.description ? b `
7480
7500
  <span class="skill-card__desc">${skill.description}</span>
7481
7501
  ` : A}
7502
+ ${!global && skill.global ? b `
7503
+ <span class="skill-card__badge">Global</span>
7504
+ ` : A}
7482
7505
  ${global ? b `
7483
7506
  <div class="skill-card__footer">
7484
7507
  <span class="skill-card__price">Free</span>
@@ -9348,6 +9371,30 @@ KRChatbot.styles = i$6 `
9348
9371
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
9349
9372
  }
9350
9373
 
9374
+ /* A Kodaris-tab card the user can't invoke — not in their roles, or not installed
9375
+ here yet. Clicking it does nothing, so it drops the pointer cursor and the hover
9376
+ highlight. The Install button inside keeps its own pointer cursor. */
9377
+ .skill-card--unavailable {
9378
+ cursor: default;
9379
+ }
9380
+
9381
+ .skill-card--unavailable:hover {
9382
+ border-color: #e5e7eb;
9383
+ box-shadow: none;
9384
+ }
9385
+
9386
+ /* Marks a globally-published skill listed on the Your Org tab — only happens on
9387
+ kodaris.com, which has no Kodaris tab for its own published skills. */
9388
+ .skill-card__badge {
9389
+ align-self: flex-start;
9390
+ padding: 2px 8px;
9391
+ border-radius: 10px;
9392
+ background: #f3f4f6;
9393
+ font-size: 11px;
9394
+ font-weight: 600;
9395
+ color: #000000;
9396
+ }
9397
+
9351
9398
  /* Footer row on global skill cards: install button on the left, price on the
9352
9399
  right. margin-top:auto pins it to the bottom so cards in a row align their
9353
9400
  footers regardless of description length. Negative horizontal margins