update owliver skill
This commit is contained in:
401
src/components/skills/SkillSections.jsx
Normal file
401
src/components/skills/SkillSections.jsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, ChevronRight, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ProgressBar } from '@/components/ds';
|
||||
|
||||
/**
|
||||
* The components a skill definition may ask for, and nothing else.
|
||||
*
|
||||
* `type: flow` in a Markdown file resolves to `FlowSection` through the table
|
||||
* at the bottom of this file. That table is the only bridge between a
|
||||
* definition and a React component: a definition names a key, the renderer
|
||||
* looks it up, and a key that is not here was already refused by validation.
|
||||
* There is no dynamic import, no component name evaluated from a string, and
|
||||
* no markup carried from the file.
|
||||
*
|
||||
* Every section is built from the existing KROW vocabulary — the same borders,
|
||||
* blue, pills and type as the rest of Admin — so an extension looks like part
|
||||
* of the product rather than like an embedded widget.
|
||||
*/
|
||||
|
||||
/** What a section shows when its data has nothing in it. Never a fake figure. */
|
||||
function EmptyNote({ children }) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sequence — periods, or the stages of a funnel.
|
||||
*
|
||||
* Built from flex and borders rather than a graph library: the shape is a row
|
||||
* of steps with connectors, and a dependency for that would be heavier than the
|
||||
* feature. Wraps to a column on narrow screens, where a horizontal flow would
|
||||
* either overflow or become unreadable.
|
||||
*/
|
||||
function FlowSection({ data }) {
|
||||
if (data.empty || !data.steps?.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
const peak = Math.max(...data.steps.map((s) => s.value || 0), 1);
|
||||
|
||||
return (
|
||||
<ol className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
{data.steps.map((step, i) => (
|
||||
<React.Fragment key={step.id}>
|
||||
{i > 0 && (
|
||||
<li aria-hidden="true" className="flex shrink-0 items-center justify-center text-ink-4">
|
||||
{/* The connector turns with the layout: down when stacked. */}
|
||||
<ChevronRight className="hidden h-4 w-4 sm:block" />
|
||||
<span className="text-[11px] sm:hidden">↓</span>
|
||||
</li>
|
||||
)}
|
||||
<li className="min-w-0 flex-1 rounded-xl border border-border bg-surface px-3 py-2.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{step.label}</p>
|
||||
<p className="mt-0.5 font-heading text-title font-bold tabular-nums text-ink-1">{step.value}</p>
|
||||
{step.detail && <p className="text-caption text-ink-3">{step.detail}</p>}
|
||||
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-surface-sunken">
|
||||
<div
|
||||
className="h-full rounded-full bg-krow-blue transition-all"
|
||||
style={{ width: `${Math.round(((step.value || 0) / peak) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** Counted figures, side by side. */
|
||||
function StatsSection({ data }) {
|
||||
const items = data.steps || data.items || [];
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
return (
|
||||
<dl className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-border bg-surface px-3 py-2.5">
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
{item.label || item.title}
|
||||
</dt>
|
||||
<dd className="mt-0.5 font-heading text-title font-bold tabular-nums text-ink-1">
|
||||
{item.value ?? '—'}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/** Records as rows, each optionally linking to the record it names. */
|
||||
function ListSection({ data }) {
|
||||
const items = data.items || [];
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||
{items.map((item) => {
|
||||
const body = (
|
||||
<>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">{item.title}</span>
|
||||
{item.detail && <span className="block truncate text-caption text-ink-3">{item.detail}</span>}
|
||||
</span>
|
||||
{item.value != null && (
|
||||
<span className="shrink-0 font-heading text-body font-bold tabular-nums text-ink-1">
|
||||
{item.value}
|
||||
</span>
|
||||
)}
|
||||
{item.to && <ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={item.id}>
|
||||
{item.to ? (
|
||||
<Link to={item.to} className="flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-surface-subtle">
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">{body}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dated events, most recent first. */
|
||||
function TimelineSection({ data }) {
|
||||
const items = [...(data.items || [])]
|
||||
.filter((item) => item.at)
|
||||
.sort((a, b) => new Date(b.at) - new Date(a.at));
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
return (
|
||||
<ol className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="flex items-baseline justify-between gap-3 text-body-sm">
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium text-ink-1">{item.title}</span>
|
||||
{item.detail && <span className="text-ink-3"> — {item.detail}</span>}
|
||||
</span>
|
||||
<span className="shrink-0 text-caption text-ink-4">
|
||||
{new Date(item.at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rows and columns, from whatever the source declared. */
|
||||
function TableSection({ data }) {
|
||||
const items = data.items || data.steps || [];
|
||||
const columns = data.columns || [
|
||||
{ key: 'label', label: 'Item' },
|
||||
{ key: 'value', label: 'Value', align: 'right' },
|
||||
];
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-body-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{columns.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'whitespace-nowrap px-3 py-2 text-[10px] font-bold uppercase tracking-wide text-ink-4',
|
||||
c.align === 'right' ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-b border-border last:border-0">
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.key}
|
||||
className={cn(
|
||||
'px-3 py-2 text-ink-2',
|
||||
c.align === 'right' ? 'text-right tabular-nums' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{item[c.key] ?? '—'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bars against a total. */
|
||||
function ProgressSection({ data }) {
|
||||
const items = data.items || data.steps || [];
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
const peak = Math.max(...items.map((i) => i.max || i.value || 0), 1);
|
||||
|
||||
return (
|
||||
<ul className="space-y-2.5">
|
||||
{items.map((item) => {
|
||||
const max = item.max || peak;
|
||||
const percent = max ? Math.round(((item.value || 0) / max) * 100) : 0;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 truncate text-body-sm capitalize text-ink-2">
|
||||
{item.title || item.label}
|
||||
</span>
|
||||
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
|
||||
{item.value ?? 0}{item.max ? `/${item.max}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={percent} tone="brand" size="sm" />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** One finding, stated plainly. */
|
||||
function InsightSection({ data }) {
|
||||
const items = data.items || data.steps || [];
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
const lead = items.reduce((best, item) => ((item.value || 0) > (best?.value || 0) ? item : best), items[0]);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 rounded-xl border border-krow-blue/30 bg-krow-blue-tint/50 px-3.5 py-3">
|
||||
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-krow-blue" aria-hidden="true" />
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||
<span className="font-semibold text-ink-1">{lead.label || lead.title}</span>
|
||||
{lead.value != null && <> — <span className="tabular-nums">{lead.value}</span></>}
|
||||
{lead.detail && <span className="text-ink-3"> · {lead.detail}</span>}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A plain panel: whatever figures the source gives, as a sentence and a row. */
|
||||
function CardSection({ data }) {
|
||||
const items = data.steps || data.items || [];
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-subtle px-3.5 py-3">
|
||||
<dl className="flex flex-wrap gap-x-6 gap-y-2">
|
||||
{items.slice(0, 6).map((item) => (
|
||||
<div key={item.id} className="min-w-0">
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
{item.label || item.title}
|
||||
</dt>
|
||||
<dd className="text-body-sm font-medium tabular-nums text-ink-1">{item.value ?? item.detail ?? '—'}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Weighted criteria that share one budget.
|
||||
*
|
||||
* Distinct from `ProgressSection`, which draws bars against independent
|
||||
* maximums. Here the rows are shares of a single total, so the total is stated
|
||||
* once for the set and flagged when it does not come to 100 — which is the only
|
||||
* thing that makes a set of weights wrong rather than merely different.
|
||||
*
|
||||
* The same component draws the page card and Owliver's compact answer; `compact`
|
||||
* only tightens spacing. Two renderings of one declaration, not two components.
|
||||
*
|
||||
* **Editing.** Controls appear only when the definition asked for them *and* the
|
||||
* surface passed an `onApply` — which a page only supplies for a source it owns.
|
||||
* Adjustments are held locally until Apply, so a half-dragged slider never
|
||||
* writes a total of 87% into the form; Apply hands the whole set over at once
|
||||
* and the page's own state is the only place it lands.
|
||||
*/
|
||||
function WeightsSection({ data, section, onApply, compact = false }) {
|
||||
const rows = React.useMemo(() => data?.steps || data?.items || [], [data]);
|
||||
const editable = Boolean(section?.editable && onApply);
|
||||
|
||||
/* Local only while editing, and re-seeded whenever the page's own values
|
||||
change — so a change made on the form shows up here even mid-edit, and
|
||||
there is never a second copy of the weights outliving the interaction. */
|
||||
const [draft, setDraft] = React.useState(null);
|
||||
const signature = React.useMemo(
|
||||
() => rows.map((r) => `${r.id}:${r.value}`).join('|'),
|
||||
[rows]
|
||||
);
|
||||
React.useEffect(() => { setDraft(null); }, [signature]);
|
||||
|
||||
if (data?.empty || !rows.length) return <EmptyNote>{data?.emptyNote}</EmptyNote>;
|
||||
|
||||
const current = draft || Object.fromEntries(rows.map((r) => [r.id, Number(r.value) || 0]));
|
||||
const total = Object.values(current).reduce((a, b) => a + b, 0);
|
||||
const balanced = total === 100;
|
||||
const dirty = draft !== null && rows.some((r) => (Number(r.value) || 0) !== current[r.id]);
|
||||
|
||||
return (
|
||||
<div className={compact ? 'space-y-2' : 'space-y-2.5'}>
|
||||
<ul className={compact ? 'space-y-1.5' : 'space-y-2.5'}>
|
||||
{rows.map((row) => {
|
||||
const value = current[row.id] ?? 0;
|
||||
return (
|
||||
<li key={row.id}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 truncate text-body-sm capitalize text-ink-2">
|
||||
{row.title || row.label}
|
||||
</span>
|
||||
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
|
||||
{value}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editable ? (
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={value}
|
||||
aria-label={`${row.title || row.label} weight`}
|
||||
onChange={(e) => setDraft({ ...current, [row.id]: Number(e.target.value) })}
|
||||
className="mt-1 w-full accent-krow-blue"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-surface-sunken">
|
||||
<div
|
||||
className="h-full rounded-full bg-krow-blue transition-all"
|
||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-2">
|
||||
<span className="text-caption text-ink-3">
|
||||
Total{' '}
|
||||
<span
|
||||
className={cn(
|
||||
'font-semibold tabular-nums',
|
||||
balanced ? 'text-success' : 'text-warning'
|
||||
)}
|
||||
>
|
||||
{total}%
|
||||
</span>
|
||||
{!balanced && <span className="text-ink-4"> — must total 100%</span>}
|
||||
</span>
|
||||
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || !balanced}
|
||||
onClick={() => { onApply(current); setDraft(null); }}
|
||||
className="inline-flex items-center rounded-lg bg-krow-blue px-2.5 py-1 text-caption font-semibold text-white
|
||||
transition-colors hover:bg-krow-blue-strong disabled:cursor-not-allowed disabled:opacity-40
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
Apply to position
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry: declared type → component.
|
||||
*
|
||||
* The only mapping from a definition to rendering. Adding a type means adding a
|
||||
* component here and a name in `surfaces.js` — never a branch on a skill id.
|
||||
*/
|
||||
export const SECTION_COMPONENTS = {
|
||||
card: CardSection,
|
||||
stats: StatsSection,
|
||||
list: ListSection,
|
||||
timeline: TimelineSection,
|
||||
flow: FlowSection,
|
||||
table: TableSection,
|
||||
progress: ProgressSection,
|
||||
insight: InsightSection,
|
||||
weights: WeightsSection,
|
||||
};
|
||||
181
src/components/skills/SkillSurface.jsx
Normal file
181
src/components/skills/SkillSurface.jsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useApplications, useAssignments, useCourses, useCurrentUser, useInterviews, useJobPostings,
|
||||
usePreferences, useStaff, useUserActivity, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { allSkills } from '@/lib/skills/registry';
|
||||
import { sectionsForPage } from '@/lib/skills/uiConfig';
|
||||
import { resolveSkillData } from '@/lib/skills/dataResolver';
|
||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { profileForEmail } from '@/lib/skillGraph';
|
||||
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
|
||||
/* The page's current selection, from the one channel that carries it. Imported
|
||||
from the module rather than the package index so a page section never pulls
|
||||
the assistant panel in behind it. */
|
||||
import { usePageAction, usePageContext } from '@/components/ai-assistant/PageContext';
|
||||
|
||||
/**
|
||||
* The extension point: one controlled slot a page offers to skills.
|
||||
*
|
||||
* A page says where a skill *may* appear; a skill definition says where it
|
||||
* *does*. Neither knows the other exists — the page names a surface and a
|
||||
* placement, the definition names the same two things, and this component is
|
||||
* what joins them. That is why a new skill needs no change to any page, and why
|
||||
* a page can be redesigned without touching a skill.
|
||||
*
|
||||
* <SkillSurface page="positions" placement="after-position-summary"
|
||||
* context={{ position }} />
|
||||
*
|
||||
* What it will not do is as important as what it does. It renders only sections
|
||||
* that survived validation, only from skills that are active, only through the
|
||||
* component table, and only with data the resolver produced from real records.
|
||||
* Nothing from the Markdown reaches the DOM as markup.
|
||||
*/
|
||||
|
||||
/** The skills contributing sections to this page right now. */
|
||||
function useSkillSections(page, placement) {
|
||||
const preferences = usePreferences();
|
||||
const customKey = JSON.stringify(preferences.customSkills || []);
|
||||
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
|
||||
|
||||
return useMemo(() => {
|
||||
const custom = JSON.parse(customKey);
|
||||
const disabled = JSON.parse(disabledKey);
|
||||
|
||||
return allSkills(custom)
|
||||
/* Inactive means registered but not offered — the same rule the assistant
|
||||
follows, so switching a skill off removes its UI too. */
|
||||
.filter((skill) => skill.status === 'active' && !disabled.includes(skill.id))
|
||||
.flatMap((skill) => sectionsForPage(skill, page)
|
||||
.filter((section) => !placement || section.placement === placement)
|
||||
.map((section) => ({ skill, section })));
|
||||
}, [page, placement, customKey, disabledKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The records a section may be read from.
|
||||
*
|
||||
* Loaded once per surface rather than per section, and passed to the resolver —
|
||||
* which is the only thing that touches them. A section never queries anything.
|
||||
*
|
||||
* Exported because the panel needs the identical reading: an answer drawn in the
|
||||
* chat and a card drawn on the page must resolve from one set of collections, or
|
||||
* the two surfaces of the same definition can disagree. Every hook inside is a
|
||||
* cache read the app has already paid for.
|
||||
*/
|
||||
export function useSkillDataContext(context) {
|
||||
/* What the page published about itself, for a surface that was not handed a
|
||||
record directly. An explicit `context` prop still wins — a card knows which
|
||||
position it is, and that is more specific than what the page says. */
|
||||
const published = usePageContext();
|
||||
const { data: applications = [] } = useApplications();
|
||||
const { data: positions = [] } = useJobPostings();
|
||||
const { data: interviews = [] } = useInterviews();
|
||||
const { data: courses = [] } = useCourses();
|
||||
const { data: workerProfiles = [] } = useWorkerProfiles();
|
||||
/* The hires and the audit trail, for the sources that read outcomes and
|
||||
events. Same caches the pages themselves render from, so a section and the
|
||||
panel beside it can never disagree. */
|
||||
const { data: staff = [] } = useStaff();
|
||||
const { data: activity = [] } = useUserActivity();
|
||||
/* Who is already committed, for the sources that score people against a role:
|
||||
the matching engine reads availability from assignments, and without them
|
||||
everybody would look free. */
|
||||
const { data: assignments = [] } = useAssignments();
|
||||
const { data: user } = useCurrentUser();
|
||||
const { statesFor } = useWorkforcePaths();
|
||||
|
||||
const trainingPaths = useMemo(
|
||||
() => statesFor(courses, profileForEmail(workerProfiles, user?.email)),
|
||||
[statesFor, courses, workerProfiles, user?.email]
|
||||
);
|
||||
|
||||
return useMemo(() => ({
|
||||
...published,
|
||||
...context,
|
||||
applications,
|
||||
positions,
|
||||
interviews,
|
||||
courses,
|
||||
workerProfiles,
|
||||
/* The workforce engine's own name for the same records. Both keys are
|
||||
published so a resolver can be written against either without a rename
|
||||
rippling through every existing source. */
|
||||
profiles: workerProfiles,
|
||||
assignments,
|
||||
staff,
|
||||
activity,
|
||||
trainingPaths,
|
||||
/* `published` belongs here: it is what changes when the reader edits the
|
||||
form this panel is sitting beside, and leaving it out froze every
|
||||
section on the first value the page ever published. */
|
||||
}), [published, context, applications, positions, interviews, courses, workerProfiles, assignments,
|
||||
staff, activity, trainingPaths]);
|
||||
}
|
||||
|
||||
/** One declared section, resolved and drawn. */
|
||||
function SkillSection({ skill, section, context }) {
|
||||
const Component = SECTION_COMPONENTS[section.type];
|
||||
const data = useMemo(
|
||||
() => resolveSkillData(section, context),
|
||||
[section, context]
|
||||
);
|
||||
|
||||
/* How this section writes back, if the page is offering that write at all.
|
||||
Null on every page that is not, which is what keeps a section declared
|
||||
`editable` honest on a surface that only reads. */
|
||||
const apply = usePageAction(section.editable ? section.source : null);
|
||||
|
||||
/* Validation refuses an unknown type long before this, so a missing component
|
||||
means the registry and the vocabulary have drifted apart. Render nothing
|
||||
rather than a broken panel. */
|
||||
if (!Component) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={section.title || skill.name}
|
||||
className="rounded-2xl border border-border bg-surface p-5 shadow-xs"
|
||||
>
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-heading text-body font-semibold text-ink-1">
|
||||
{section.title || skill.name}
|
||||
</h3>
|
||||
{section.description && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{section.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Attribution, so an admin can tell an extension from a built-in panel
|
||||
and knows which skill to switch off. */}
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-krow-blue-tint px-2 py-0.5 text-[10px] font-semibold text-krow-blue">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
{skill.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Component data={data} section={section} onApply={apply} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillSurface({ page, placement, context = null, className }) {
|
||||
const sections = useSkillSections(page, placement);
|
||||
const resolved = useSkillDataContext(context);
|
||||
|
||||
if (!sections.length) return null;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{sections.map(({ skill, section }) => (
|
||||
<SkillSection
|
||||
key={`${skill.id}:${section.id}`}
|
||||
skill={skill}
|
||||
section={section}
|
||||
context={resolved}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user