archive issue fix
Some checks failed
CI / check (push) Failing after 5m5s

This commit is contained in:
2026-09-10 19:29:39 +05:30
parent 6249e00a3a
commit f522b6508e
20 changed files with 2444 additions and 207 deletions

View File

@@ -25,6 +25,7 @@ import WorkerProfile from '@/pages/WorkerProfile';
import KrowIdentity from '@/pages/KrowIdentity';
import Owliver from '@/pages/Owliver';
import EmployeeDashboard from '@/pages/EmployeeDashboard';
import OpportunityDetail from '@/pages/OpportunityDetail';
import DesignSystem from '@/pages/DesignSystem';
/* Admin product — its own layout and route tree, so the Admin redesign can
@@ -137,6 +138,12 @@ const AuthenticatedApp = () => {
</Route>
{/* Redirect top-level legacy aliases to the unified global shell */}
{/* Employee (talent) opportunity flow — full-page routes, no drawer.
Standalone (outside AdminLayout/AdminRoute, which are the employer
shell and gate); authenticated via ProtectedRoute. */}
<Route path="/employee" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-5xl mx-auto px-4 py-8"><EmployeeDashboard /></div></div>} />
<Route path="/opportunities/:id" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-3xl mx-auto px-4 py-8"><OpportunityDetail /></div></div>} />
<Route path="/apply" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-3xl mx-auto px-4 py-8"><Apply /></div></div>} />
<Route path="/overview" element={<Navigate to="/admin" replace />} />
<Route path="/positions" element={<Navigate to="/admin/positions" replace />} />
{/* `/positions` only matches the exact path, so the sub-routes need

View File

@@ -45,6 +45,9 @@ export function resolveUiEdit({ question, ui }) {
page can actually hold — the same scope the visual editor's picker uses,
so the two can never disagree about what is addable here. */
page: ui.page,
/* The node this conversation last changed. Nothing is remembered inside the
matcher: continuity is a fact the caller holds and passes in. */
focus: ui.focus || null,
});
if (!match) return null;
@@ -155,8 +158,52 @@ export function resolveUiEdit({ question, ui }) {
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
};
case 'refused':
return { kind: 'ui-answer', doc: doc(text(match.message)) };
/**
* Understood, and not possible — with the reason and the way forward.
*
* A refusal that only says no leaves a person guessing at a vocabulary they
* cannot see. When the engine knows what this reading *could* be drawn as,
* it says so and offers the choices as chips, so "no, but here" costs one
* click rather than another round of guessing.
*/
case 'refused': {
const offered = match.alternatives || [];
return {
kind: 'ui-answer',
doc: offered.length
? doc(
text(match.message),
text(`It can be shown as: ${offered.map((o) => o.label).join(', ')}.`)
)
: doc(text(match.message)),
followUp: offered.slice(0, 3).map((option) => ({
label: option.label,
prompt: `Show ${match.node?.title || match.node?.label || 'it'} as a ${option.label}`,
})),
};
}
/**
* Every way this reading could honestly be drawn.
*
* The options are the registry's answer, not a suggestion: each is a
* component the application ships, each will draw this node's own figures,
* and choosing one produces exactly the operation `planReplace` would have
* produced from the same words. Nothing here is generated.
*/
case 'options':
return {
kind: 'ui-answer',
doc: doc(
text(`${match.subject} can be drawn these ways. Each one uses the same figures.`),
list(match.options.map((option) => `${option.label} — ${option.summary}`)),
note('Pick one to preview it. Nothing is saved until you apply.')
),
followUp: match.options.map((option) => ({
label: option.label,
prompt: `Show ${match.node.title || match.node.label} as a ${option.label}`,
})),
};
default:
return null;

View File

@@ -588,6 +588,10 @@ export function useConversation({
property of the page, not of the registry, and this is how
the conversation learns it. */
page: uiEditing.page || null,
/* What this session last changed, so "change it back" has an
"it". A node id and nothing else — see `focus` on the
editing provider. */
focus: uiEditing.focus || null,
}
: null,
}),

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Briefcase, MapPin, Wallet, Sparkles, Compass, ArrowRight } from 'lucide-react';
/**
* Opportunities — the Employee opportunity-discovery grid.
*
* Replaces the old demand-feed cards on the Employee jobs surface. Each card is
* an opportunity, identified by its JOB TITLE — never a company/client name.
* "View Position" navigates (route-based, no drawer) to the full-page detail at
* /opportunities/:id.
*
* It takes the same `{ job, match }` records the dashboard already computes with
* recommendJobs(profile, postings, 6); nothing about the matching changes here.
*
* COMPANY PRIVACY: this component reads only the fields the Employee experience
* needs — id, title, role_category, location, pay_range_min/max, status, match —
* and never `job.company`.
*/
function OpportunityCard({ rec }) {
const navigate = useNavigate();
const { job, match } = rec;
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : null;
const isHiring = job.status === 'active';
return (
<button
type="button"
onClick={() => navigate(`/opportunities/${job.id}`)}
className="group text-left w-full rounded-2xl bg-white border border-[#E5E7EB] p-5 transition-all
hover:border-[#0838E0]/40 hover:shadow-lg hover:shadow-[#0838E0]/5 hover:-translate-y-0.5
focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
>
<div className="flex items-start justify-between gap-3">
<div className="w-11 h-11 rounded-xl bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center shrink-0">
<Briefcase className="w-5 h-5" />
</div>
{isHiring && (
<span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full">
Hiring
</span>
)}
</div>
{/* The job title is the opportunity's identity — no company name. */}
<h3 className="mt-3 text-[16px] font-bold text-[#0F172A] leading-snug line-clamp-2">{job.title}</h3>
{job.role_category && (
<p className="mt-0.5 text-[13px] text-[#6B7280] capitalize">{job.role_category}</p>
)}
<div className="mt-3 space-y-1.5">
<p className="text-[13px] text-[#6B7280] flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-[#9CA3AF]" /> {job.location || 'Remote'}
</p>
{pay && (
<p className="text-[13px] font-medium text-[#111827] flex items-center gap-1.5">
<Wallet className="w-3.5 h-3.5 text-[#9CA3AF]" /> {pay}
</p>
)}
</div>
<div className="mt-4 flex items-center justify-between pt-4 border-t border-[#F3F4F6]">
<span className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0838E0] bg-[#EEF3FE] px-2.5 py-1 rounded-full">
<Sparkles className="w-3 h-3" /> {match}% match
</span>
<span className="inline-flex items-center gap-1 text-[13px] font-semibold text-[#0838E0] group-hover:gap-1.5 transition-all">
View Position <ArrowRight className="w-4 h-4" />
</span>
</div>
</button>
);
}
export default function Opportunities({ jobRecs = [] }) {
return (
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
<div className="flex items-center justify-between mb-5">
<div>
<h2 className="text-[18px] font-bold text-[#0F172A] flex items-center gap-2">
<Compass className="w-4 h-4 text-[#0838E0]" /> Opportunities for you
</h2>
<p className="text-[13px] text-[#6B7280] mt-0.5">Matched to your profile. Open one to see why you fit.</p>
</div>
{jobRecs.length > 0 && (
<span className="inline-flex items-center text-[12px] font-semibold text-[#0838E0] bg-[#EEF3FE] px-3 py-1.5 rounded-full">
{jobRecs.length} match{jobRecs.length === 1 ? '' : 'es'}
</span>
)}
</div>
{jobRecs.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3">
<Compass className="w-6 h-6" />
</div>
<p className="text-[14px] font-medium text-[#0F172A]">No matched opportunities yet — keep leveling up.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{jobRecs.map((rec) => (
<OpportunityCard key={rec.job.id} rec={rec} />
))}
</div>
)}
</section>
);
}

View File

@@ -6,6 +6,7 @@ import {
ALIGN_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, SPACING_VALUES,
} from '@/lib/ui/node';
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
import { kindOfBinding } from '@/lib/ui/series';
import { nodeRegistry } from '@/lib/ui/registry';
import { hideOp, layoutOp, nudgeOp, presentationOp, propOp, removeOp, replaceOp } from './ops';
@@ -43,8 +44,25 @@ export function NodeInspector({ node, siblings, page = null, onOperate, registry
if (op) onOperate(op);
};
const shapes = node.data ? dataSourceFor(node.data.source)?.shapes || [] : null;
const replacements = can('replace') ? registry.replacements(node.type, { shapes, page }) : [];
/**
* What this node could be shown as.
*
* The same five facts the conversation passes, in the same order, to the same
* registry call — the editor holds no compatibility rule of its own, so a
* type in this list is exactly a type "show this as a …" would accept. Two
* readings of the rule is how a picker comes to offer something the operation
* then refuses.
*/
const shapes = node.data?.source ? dataSourceFor(node.data.source)?.shapes || [] : null;
const replacements = can('replace')
? registry.replacements(node.type, {
shapes,
page,
seriesKind: kindOfBinding(node.data),
seriesBound: Boolean(node.data?.series),
bound: Boolean(node.data),
})
: [];
return (
<div className="space-y-4">

View File

@@ -33,6 +33,20 @@ const UiEditingContext = React.createContext(null);
/** The editing session for the page around it. Null outside a provider. */
export const useUiEditing = () => React.useContext(UiEditingContext);
/**
* The node an operation is about, or null.
*
* Read from the operation's own shape rather than from a table of operation
* names with special cases: every operation that concerns one node names it in
* `target`, and `add` names the node it is creating. `reorder` concerns a whole
* list and has no single subject, so it leaves the previous one standing —
* which is right: reordering the page does not change what "it" means.
*/
function subjectOf(op) {
const named = String(op?.target ?? op?.node?.id ?? '').trim();
return named || null;
}
export function UiEditingProvider({ page, role = null, registry = nodeRegistry, children }) {
const { layouts, save, saving } = useUiLayouts();
@@ -44,6 +58,14 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
*/
const [preview, setPreview] = React.useState(() => emptyPatch(page));
const [problems, setProblems] = React.useState([]);
/**
* The subject of the conversation, as a node id.
*
* What makes "change it back to a line chart" answerable. Recorded from the
* operation that stood, so it is always a node that really exists and really
* changed — never a guess, and never something the model supplied.
*/
const [focus, setFocus] = React.useState(null);
/* A page change is a new editing session. Anything unsaved was about the page
that is no longer on screen. */
@@ -91,6 +113,10 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
}
setProblems([]);
setPreview((current) => pushOp(current, op));
/* Only once the operation stood. A refused change never becomes the thing
"it" refers to. */
const named = subjectOf(op);
if (named) setFocus(named);
return result;
}, [composed.tree, registry, role]);
@@ -98,6 +124,7 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
const discard = React.useCallback(() => {
setPreview(emptyPatch(page));
setProblems([]);
setFocus(null);
}, [page]);
/**
@@ -141,6 +168,7 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
const reset = React.useCallback(async () => {
setPreview(emptyPatch(page));
setProblems([]);
setFocus(null);
if (saved.ops.length) await save(page, clearOps(saved));
return { ok: true };
}, [page, saved, save]);
@@ -156,6 +184,15 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
preview,
problems,
previewing: preview.ops.length > 0,
/**
* The node this session last changed, and therefore what "it" means.
*
* Session state, not stored state: it is a fact about the conversation
* rather than about the layout, so it is deliberately not persisted and is
* gone on reload. Kept across an Apply — applying does not end the subject —
* and cleared by Discard and Reset, which do.
*/
focus,
customised: saved.ops.length > 0,
saving,
propose,
@@ -164,7 +201,7 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
undo,
reset,
}), [
page, composed, saved, preview, problems, saving, propose, discard, apply, undo, reset,
page, composed, saved, preview, problems, saving, focus, propose, discard, apply, undo, reset,
]);
return <UiEditingContext.Provider value={value}>{children}</UiEditingContext.Provider>;

View File

@@ -1,74 +1,79 @@
import React from 'react';
import {
Bar, BarChart, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart,
ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { cn } from '@/lib/utils';
import { AXIS_PROPS, CHART_COLORS, CHART_TONES } from '@/components/ds/ChartContainer';
import { SECTION_TYPES } from '@/lib/skills/surfaces';
import { SegmentedToggle } from '@/components/ds/Toggle';
import { useSkillDataContext } from '@/components/skills/SkillSurface';
import { resolveSkillData } from '@/lib/skills/dataResolver';
import { DENSITY_VALUES, VARIANT_VALUES } from '@/lib/ui/node';
import { registerNodeType } from '@/lib/ui/registry';
import { layoutClasses, presentationClasses } from './UiTreeRenderer';
import { controlOfBinding, readSeries } from '@/lib/ui/series';
import { layoutClasses, presentationClasses, useUiContext } from './UiTreeRenderer';
/**
* Two ways of drawing a series: as bars, and as parts of a whole.
* Five ways of drawing one series, and one table that says which is which.
*
* These are the first node types that are **not** skill section types. The nine
* readings exist in `surfaces.js` because a Markdown definition may name them;
* chart and pie are named by nobody but this registry, so adding them here adds
* nothing to the MD vocabulary, obliges no change in the Go parser, and cannot
* drift from its conformance oracle. A person reaches them by turning an
* existing node into one.
* These are the node types a person reaches by asking for them — "show this as
* a bar chart" — and everything that decides whether they may is declared here
* as data. There is no branch anywhere on which chart is being drawn: the
* registry compares `seriesKinds` to what the binding means, and a sixth chart
* added to `VISUALIZATIONS` below is addressable by name, in conversation and
* in the editor, with no other file touched.
*
* **Nothing is transformed and nothing is invented.** A `flow` or a `stats`
* reading already resolves to `{ steps: [{ id, label, value }] }` — a labelled
* numeric series, which is precisely what a bar chart and a pie chart each
* need. So these draw the same payload the Flow section draws, from the same
* resolver, keyed by the same source. There is no adapter layer here because
* there is nothing to adapt.
* **Nothing is transformed and nothing is invented.** Every one of these reads
* `readSeries`, which either resolves a skill reading through the ordinary
* resolver or calls a page's own registered `read` — and then relabels neither.
* A bar chart of hiring activity draws the same four numbers per day that the
* page's own composed chart draws, because it is literally the same array.
*
* The consequence is the compatibility rule, and it is the existing one: a
* source can become a chart exactly when it declares `flow` or `stats`, decided
* by `sourceSupportsShape` like every other replacement. No new shape, no new
* vocabulary, no per-source table.
* The interesting column is `seriesKinds`, and it is the whole reason a person
* can be told *why* rather than merely *no*. `flow` cannot express it — it is,
* in `SECTION_TYPES`' own words, "a sequence of stages **or** periods" — so a
* pie was structurally free to slice twelve days into twelve wedges. Saying
* `parts` here is what makes that impossible, and the refusal explains itself
* in terms of the reading rather than the component.
*/
/** The series a reading resolved to, in the one shape both charts read. */
/** The series this node is bound to, resolved through whichever binding it has. */
function useSeries(node) {
const context = useSkillDataContext(null);
const section = React.useMemo(() => ({
const skillContext = useSkillDataContext(null);
const pageContext = useUiContext();
const resolveSource = React.useCallback((binding) => resolveSkillData({
id: node.id,
/* `flow` because that is the shape whose payload these draw. Resolvers are
keyed by source and never read this, but a section without a type is not
a section, and naming the shape it consumes keeps that honest. */
type: 'flow',
source: node.data?.source || '',
periods: node.data?.params?.periods || [],
limit: node.data?.params?.limit || null,
}), [node.id, node.data]);
source: binding.source,
periods: binding.params?.periods || [],
limit: binding.params?.limit || null,
}, skillContext), [node.id, skillContext]);
const data = React.useMemo(() => resolveSkillData(section, context), [section, context]);
const rows = (data.steps || data.items || [])
.map((step, i) => ({
id: step.id || `${i}`,
label: String(step.label || step.title || ''),
value: Number(step.value) || 0,
}))
/* A slice of nothing is not a slice, and a bar of nothing is a gap. Both
charts read real numbers or draw the empty note. */
.filter((row) => row.label);
return { data, rows };
return React.useMemo(
() => readSeries(node.data, { context: pageContext, resolveSource }),
[node.data, pageContext, resolveSource]
);
}
/** How tall the plot is, and therefore how big a pie fits in it. */
const plotHeight = (presentation) => (presentation?.density === 'compact' ? 160 : 240);
const plotHeight = (presentation) => (presentation?.density === 'compact' ? 180 : 260);
/** The panel both share — the same chrome every reading section draws. */
function ChartPanel({ node, attrs, layout, presentation, title, children, empty }) {
/**
* The panel every one of them shares — the same chrome a reading section draws.
*
* `control` is whatever the *reading* publishes, drawn here so that replacing
* one visualization with another does not quietly take a control away. Nothing
* about it is known to this file beyond the shape declared in `series.js`: a
* label, a closed set of options, the value the page is publishing, and the
* page's own setter. A reading that publishes none renders none.
*/
function ChartPanel({ attrs, layout, presentation, title, children, empty, control = null }) {
const look = presentationClasses(presentation);
const height = plotHeight(presentation);
return (
<section
@@ -76,95 +81,210 @@ function ChartPanel({ node, attrs, layout, presentation, title, children, empty
aria-label={title || undefined}
className={cn('rounded-2xl border', look.variant, look.padding, layoutClasses(layout))}
>
{title && (
<div className={look.gap}>
<h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>{title}</h3>
{(title || control) && (
<div className={cn('flex flex-wrap items-center justify-between gap-x-3 gap-y-2', look.gap)}>
{title
? <h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>{title}</h3>
: <span />}
{control && (
<SegmentedToggle
options={control.options}
value={control.value}
onChange={control.set}
size="sm"
ariaLabel={control.label}
/>
)}
</div>
)}
{empty
? <p className="text-caption text-ink-3">{empty}</p>
: <div style={{ height }}>{children}</div>}
: <div style={{ height: plotHeight(presentation) }}>{children}</div>}
</section>
);
}
/** Bars, one per step, in the brand's own colour. */
function ChartNode({ node, attrs = {}, layout = null, presentation = null, title = null }) {
const { data, rows } = useSeries(node);
const empty = !rows.length ? (data.emptyNote || 'Nothing to chart yet.') : null;
/** The axes, grid, tooltip and legend every cartesian chart here draws alike. */
function Cartesian({ measures }) {
return (
<>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={16} height={32} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={32} />
<Tooltip cursor={{ fill: CHART_TONES.mint, fillOpacity: 0.25 }} />
{/* One series needs no key: the title already names it. */}
{measures.length > 1 && (
<Legend
verticalAlign="top" align="right" height={24}
iconType="circle" iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
)}
</>
);
}
const colour = (i) => CHART_COLORS[i % CHART_COLORS.length];
/**
* The five, as data.
*
* `draw` returns the recharts element for a resolved series; everything else —
* the panel, the empty state, the identity, layout and presentation — is shared
* above and below. Adding one is adding a row.
*/
const VISUALIZATIONS = [
{
type: 'bar-chart',
label: 'Bar chart',
summary: 'A bar for each point in a reading.',
/* Bars compare magnitudes and claim nothing about order or wholeness, which
is why this is the one drawing every meaning can take. */
seriesKinds: ['periodic', 'cumulative', 'parts'],
draw: ({ rows, measures }) => (
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<Cartesian measures={measures} />
{measures.map((measure, i) => (
<Bar key={measure.key} dataKey={measure.key} name={measure.label}
fill={colour(i)} radius={[4, 4, 0, 0]} />
))}
</BarChart>
),
},
{
type: 'line-chart',
label: 'Line chart',
summary: 'The points joined in order.',
/* A line asserts that the gap between two points means something, so it may
only draw readings that are actually ordered. */
seriesKinds: ['periodic', 'cumulative'],
draw: ({ rows, measures }) => (
<LineChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<Cartesian measures={measures} />
{measures.map((measure, i) => (
<Line key={measure.key} type="monotone" dataKey={measure.key} name={measure.label}
stroke={colour(i)} strokeWidth={2} dot={false} isAnimationActive={false} />
))}
</LineChart>
),
},
{
type: 'area-chart',
label: 'Area chart',
summary: 'The points joined in order, filled to the axis.',
seriesKinds: ['periodic', 'cumulative'],
draw: ({ rows, measures }) => (
<AreaChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<Cartesian measures={measures} />
{measures.map((measure, i) => (
<Area key={measure.key} type="monotone" dataKey={measure.key} name={measure.label}
stroke={colour(i)} strokeWidth={2} fill={colour(i)} fillOpacity={0.18}
isAnimationActive={false} />
))}
</AreaChart>
),
},
{
type: 'pie-chart',
label: 'Pie chart',
summary: 'The points as parts of one whole.',
/* The narrow one, and deliberately. A slice is a share of a total, which is
only true when the points are disjoint parts of that total. */
seriesKinds: ['parts'],
draw: ({ rows, measures }) => <Slices rows={rows} measure={measures[0]} inner={0} />,
/* A disc of zeroes is a blank panel that every automated check calls a
success because the elements are there. */
guard: ({ rows, measures }) => (
rows.reduce((sum, row) => sum + (row[measures[0]?.key] || 0), 0) === 0
? 'Every value here is zero.' : null
),
},
{
type: 'donut-chart',
label: 'Donut chart',
summary: 'The same parts, drawn as a ring.',
seriesKinds: ['parts'],
draw: ({ rows, measures }) => <Slices rows={rows} measure={measures[0]} inner={0.55} />,
guard: ({ rows, measures }) => (
rows.reduce((sum, row) => sum + (row[measures[0]?.key] || 0), 0) === 0
? 'Every value here is zero.' : null
),
},
];
/**
* The wedges a pie and a donut share.
*
* Centre and radii given explicitly, in pixels. Percentage radii with no
* `cx`/`cy` produced sectors that existed in the DOM, carried no fill and drew
* nothing — the failure the guard above is about. The one pie already in this
* repository does it this way, and it works.
*/
function Slices({ rows, measure, inner }) {
const outer = 92;
return (
<PieChart>
<Pie
data={rows}
dataKey={measure?.key || 'value'}
nameKey="label"
cx="50%"
cy="50%"
innerRadius={Math.round(outer * inner)}
outerRadius={outer}
isAnimationActive={false}
>
{rows.map((row, i) => <Cell key={row.id} fill={colour(i)} />)}
</Pie>
<Tooltip />
</PieChart>
);
}
/** One visualization node — identical for all five but for the `draw` it was given. */
function Visualization({ spec, node, attrs = {}, layout = null, presentation = null, title = null }) {
const series = useSeries(node);
const pageContext = useUiContext();
const empty = !series.rows.length || !series.measures.length
? (series.emptyNote || 'Nothing to chart yet.')
: (spec.guard?.(series) || null);
/**
* The control the reading publishes, and the reading's own name.
*
* Both exist so that a replacement is a change of renderer rather than a
* quiet loss of what was on the panel. The title falls back to the name of
* the thing being drawn — not to a made-up one — so a built-in section
* replaced by a chart still says what it is showing.
*/
const control = controlOfBinding(node.data, pageContext);
return (
<ChartPanel node={node} attrs={attrs} layout={layout} presentation={presentation} title={title} empty={empty}>
<ChartPanel attrs={attrs} layout={layout} presentation={presentation}
title={title || series.label || null} empty={empty} control={control}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<XAxis dataKey="label" {...AXIS_PROPS} interval={0} height={40} />
<YAxis {...AXIS_PROPS} allowDecimals={false} />
<Tooltip cursor={{ fill: CHART_TONES.mint, fillOpacity: 0.25 }} />
<Bar dataKey="value" fill={CHART_TONES.brand} radius={[4, 4, 0, 0]} />
</BarChart>
{spec.draw(series)}
</ResponsiveContainer>
</ChartPanel>
);
}
/** The same series as parts of a whole. */
function PieNode({ node, attrs = {}, layout = null, presentation = null, title = null }) {
const { data, rows } = useSeries(node);
const total = rows.reduce((sum, row) => sum + row.value, 0);
/* A pie of nothing is a blank disc, so an all-zero series is empty rather
than drawn — the reading has values, they are simply all zero. */
const empty = !rows.length
? (data.emptyNote || 'Nothing to chart yet.')
: (total === 0 ? 'Every value here is zero.' : null);
const height = plotHeight(presentation);
const radius = { outer: Math.round(height * 0.38) };
return (
<ChartPanel node={node} attrs={attrs} layout={layout} presentation={presentation} title={title} empty={empty}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
{/* Centre and radii given explicitly, in pixels.
Percentage radii with no `cx`/`cy` produced sectors that existed in
the DOM, carried no fill and drew nothing — a blank panel that
every automated check called a success because the elements were
there. The one pie already in this repository does it this way,
and it works. */}
<Pie
data={rows}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
outerRadius={radius.outer}
isAnimationActive={false}
>
{rows.map((row, i) => (
<Cell key={row.id} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</ChartPanel>
);
}
/* The shapes whose payload both of these draw, read from the vocabulary rather
than written out, so a shape renamed there cannot leave these claiming one
that no longer exists. */
/* The shapes whose payload these draw, read from the vocabulary rather than
written out, so a shape renamed there cannot leave these claiming one that no
longer exists. */
const SERIES_SHAPES = ['flow', 'stats'].filter((shape) => SECTION_TYPES.some((t) => t.id === shape));
for (const [type, label, summary, component] of [
['chart', 'Chart', 'A bar for each step in a reading.', ChartNode],
['pie', 'Pie', 'The same steps as parts of a whole.', PieNode],
]) {
for (const spec of VISUALIZATIONS) {
const Component = (props) => <Visualization spec={spec} {...props} />;
Component.displayName = spec.label;
registerNodeType({
type,
label,
summary,
component,
type: spec.type,
label: spec.label,
summary: spec.summary,
component: Component,
dataShapes: SERIES_SHAPES,
seriesKinds: spec.seriesKinds,
dataRequired: true,
propSchema: { title: { type: 'string', label: 'Title' } },
variants: VARIANT_VALUES,
@@ -174,3 +294,5 @@ for (const [type, label, summary, component] of [
capabilities: ['update', 'remove', 'move', 'replace', 'hide'],
});
}
export { VISUALIZATIONS };

View File

@@ -524,6 +524,20 @@ export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label ||
* position id, a candidate id, or nothing. A source is resolved by
* `dataResolver.js`; a definition cannot reach a store directly, cannot write,
* and cannot name a field that is not offered here.
*
* **`series`** is the optional half that says what a reading's figures *mean*,
* as one of the closed kinds in `SERIES_KINDS` — `periodic` (ordered in time),
* `cumulative` (each step drawn from the one before) or `parts` (disjoint
* shares of one whole). `shapes` answers whether a component *can* draw a
* reading; this answers whether doing so would be true, and it is what refuses
* a pie chart of a timeline.
*
* It is declared only where the reading's own definition already states the
* meaning, and it is deliberately absent from most of them. An absent `series`
* is "not established", and a component that has declared which meanings it
* draws refuses rather than guesses — which is the safe direction. Adding one
* is a statement about what the resolver actually returns, so it is added when
* that is known and not before.
*/
export const DATA_SOURCES = [
{
@@ -532,6 +546,9 @@ export const DATA_SOURCES = [
context: 'positionId',
summary: 'Applications to this position, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
/* "counted over time": the resolver buckets by period and returns
the buckets in order, so the order is part of the reading. */
series: 'periodic',
options: ['periods'],
},
{
@@ -540,6 +557,9 @@ export const DATA_SOURCES = [
context: 'positionId',
summary: 'Applied → screened → shortlisted → interviewed → hired.',
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
/* A funnel: everyone screened applied first, so the stages overlap
and do not add up to a whole. */
series: 'cumulative',
},
{
id: 'position.candidates',
@@ -594,6 +614,9 @@ export const DATA_SOURCES = [
context: null,
summary: 'Every candidate, counted by stage.',
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
/* The same funnel across the workspace, and cumulative for the same
reason. */
series: 'cumulative',
},
{
id: 'candidates.activity',
@@ -601,6 +624,8 @@ export const DATA_SOURCES = [
context: null,
summary: 'Applications across the workspace, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
/* "counted over time", as above. */
series: 'periodic',
options: ['periods'],
},
{
@@ -738,6 +763,9 @@ export const DATA_SOURCES = [
context: null,
summary: 'Events by type and by account.',
shapes: ['stats', 'table', 'list', 'progress', 'flow', 'card'],
/* Every event has exactly one type and one account, so counting by
either partitions the same total: these are shares of one whole. */
series: 'parts',
options: ['periods', 'limit'],
},
{

View File

@@ -16,7 +16,8 @@
* name, page name or component name appears below.
*/
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
import { dataSourceFor } from '@/lib/skills/surfaces';
import { kindOfBinding, labelOfBinding, seriesFor } from './series';
import { locate, walk } from './node';
import { nodeRegistry } from './registry';
@@ -44,8 +45,23 @@ export function describeNode(node, { registry = nodeRegistry, parent = null, ind
* supplies a `describe` and says where this one is; nothing here knows what
* a placement is.
*/
/**
* Last, the name of what it is reading.
*
* Identity has to survive a replacement. A built-in section carries its
* name in its type label — "Hiring activity" — and replacing it with a
* chart left a node whose only name was "Bar chart", so the very phrase
* that had just worked stopped resolving and "change it back" answered
* that there was no such thing on the page.
*
* The binding is the continuous fact across a replacement: the node is
* still reading the same series, and the series has a name. Using it is
* not a fallback invented for charts — a node is named by what it shows,
* which is how a person refers to it either way.
*/
title: String(node.props?.title || '').trim()
|| (typeof entry?.describe === 'function' ? String(entry.describe(node) || '').trim() || null : null),
|| (typeof entry?.describe === 'function' ? String(entry.describe(node) || '').trim() || null : null)
|| (node.data ? String(labelOfBinding(node.data) || '').trim() || null : null),
known: Boolean(entry),
container: Boolean(entry?.container),
origin: node.origin,
@@ -53,12 +69,24 @@ export function describeNode(node, { registry = nodeRegistry, parent = null, ind
locked: node.locked === true,
parent: parent?.id ?? null,
index,
/**
* The binding, in whichever of the two kinds it is.
*
* A described node is what the conversation and the editor both reason
* about, so it has to carry enough to answer "what can this become" — and
* that now includes a page's own series. Projected rather than passed
* through so a consumer still cannot reach a resolver from here.
*/
data: node.data
? {
source: node.data.source,
label: dataSourceLabel(node.data.source),
source: node.data.source || null,
series: node.data.series || null,
label: labelOfBinding(node.data),
kind: kindOfBinding(node.data),
params: node.data.params || {},
known: Boolean(dataSourceFor(node.data.source)),
known: node.data.series
? Boolean(seriesFor(node.data.series))
: Boolean(dataSourceFor(node.data.source)),
}
: null,
layout: { ...(node.layout || {}) },
@@ -120,11 +148,15 @@ export function outlineTree(nodes, { registry = nodeRegistry } = {}) {
for (const node of list || []) {
const entry = registry.get(node.type);
const title = String(node.props?.title || '').trim();
/* Named by the reading when it has one, for the same reason `describeNode`
is: it is what a person calls the thing, and it survives a change of
component. */
const reading = node.data ? String(labelOfBinding(node.data) || '').trim() : '';
const bits = [
`${' '.repeat(depth)}${title || entry?.label || node.type}`,
`${' '.repeat(depth)}${title || reading || entry?.label || node.type}`,
`(${node.id})`,
node.hidden ? '· hidden' : '',
node.data?.source ? `· ${dataSourceLabel(node.data.source)}` : '',
reading && reading !== (title || reading) ? `· ${reading}` : '',
].filter(Boolean);
lines.push(bits.join(' '));
if (node.children?.length) visit(node.children, depth + 1);
@@ -173,7 +205,10 @@ export function resolveTarget(nodes, phrase, {
const entry = registry.get(node.type);
const title = canon(node.props?.title);
const label = canon(entry?.label || node.type);
const source = canon(node.data ? dataSourceLabel(node.data.source) : '');
/* `labelOfBinding`, not `dataSourceLabel`: a node bound to a page's own
series has no source id, and scoring only the source made every such
node unfindable by the name of the thing it draws. */
const source = canon(node.data ? labelOfBinding(node.data) : '');
const id = canon(node.id);
let score = 0;

View File

@@ -29,6 +29,7 @@ import { SUPPORTED_DATA_SOURCES, dataSourceFor, dataSourceLabel } from '@/lib/sk
import { addableTypes, describeNode, resolveTarget } from './inspect';
import { freeNodeId, walk } from './node';
import { nodeRegistry } from './registry';
import { kindOfBinding, labelOfBinding, refuseSeries } from './series';
/** Lower-case, punctuation-free. The one normaliser, shared with `inspect`. */
const canon = (value) => String(value ?? '')
@@ -62,6 +63,28 @@ const VERBS = [
*/
{ op: 'present', words: ['compact', 'comfortable', 'spacious', 'emphasis', 'emphasise', 'emphasize', 'subtle', 'denser', 'tighter', 'roomier'] },
/**
* "I don't like this. What else could it be?"
*
* Before `replace`, because the sentences overlap — "show me other options"
* and "show me some other designs" both contain words `replace` and `show`
* would claim — and only this one is a question rather than an instruction.
*
* Every phrase is two words or more on purpose. A bare "options" is what a
* person says about a position or a shift, and stealing it would turn an
* ordinary question into a layout answer.
*/
{ op: 'options', words: [
'other options', 'other option', 'more options', 'some other options',
'other designs', 'another design', 'different design', 'other design',
'alternatives', 'alternative', 'other ways', 'another way', 'different way',
'other visualisations', 'other visualizations', 'other visualisation',
'other visualization', 'another visualisation', 'another visualization',
'different visualisation', 'different visualization',
'other look', 'another look', 'different look',
'what else can', 'what else could', 'what else is', 'something else',
] },
{ op: 'replace', words: ['change', 'turn', 'switch', 'convert', 'make it a', 'show as', 'show it as'] },
{ op: 'add', words: ['add', 'insert', 'create a', 'put a new'] },
{ op: 'layout', words: ['columns', 'column', 'side by side', 'two up', 'wider', 'narrower'] },
@@ -94,6 +117,27 @@ const UI_WORDS = [
const SHOW_CONFIDENCE = 18;
/**
* The words that mean "the thing we were just talking about".
*
* Deliberately a closed list of pronouns and demonstratives, and deliberately
* not a general reference resolver. A phrase carrying one of these, that names
* nothing else on the page, is asking about whatever was last acted on — and
* saying so out loud is what makes the fallback safe: a request that *does*
* name something is never redirected, and a request that names nothing and
* says nothing pronominal is still unknown.
*
* "back" and "again" are here because "change it back" and "do that again" are
* how the follow-up is actually said.
*/
const ANAPHORA = new Set([
'it', 'this', 'that', 'these', 'those', 'them', 'same',
'back', 'again', 'instead',
]);
/** Does this phrase refer to something already under discussion? */
const refersBack = (text) => text.split(' ').some((word) => ANAPHORA.has(word));
const NUMBER_WORDS = { one: 1, two: 2, three: 3, four: 4, six: 6, twelve: 12 };
/**
@@ -109,11 +153,36 @@ export function matchUiEdit(question, {
keeps "add a recent hiring timeline" from being answerable on a page that
publishes none of the records such a section reads. */
page = null,
/**
* The node this conversation last acted on, if any.
*
* The whole of "it". A conversation about a page has a subject, and asking a
* person to re-name it in every sentence is not how anyone speaks — "change
* Hiring activity to a bar chart" followed by "change it back to a line
* chart" is one thought in two sentences, and the second one used to be
* answered with "I could not find that on this page."
*
* An id, and only an id: the caller records which node an operation named
* and hands it back next turn. Nothing is remembered here, nothing is
* inferred from the model, and a focus that is no longer in the tree simply
* does not resolve.
*/
focus = null,
} = {}) {
const text = canon(question);
if (!text) return null;
const verb = VERBS.find((v) => has(text, ...v.words));
/**
* The verb, from the table — or from the registry's own nouns.
*
* "I want a different chart" asks the same question as "show me other
* options" and shares not one word with it. The phrase table cannot grow to
* cover it without writing component names into the language layer, so the
* second reading derives the noun from the registry instead. See
* `asksToRedraw`.
*/
const verb = VERBS.find((v) => has(text, ...v.words))
|| (asksToRedraw(text, registry) ? VERBS.find((v) => v.op === 'options') : null);
if (!verb) return null;
/**
@@ -160,18 +229,36 @@ export function matchUiEdit(question, {
if (verb.op === 'show') return planShow(text, tree, registry);
if (verb.op === 'unhide') return planUnhide(text, tree, registry);
/**
* "Show me something else" is asked before the interface-words gate.
*
* The gate wants a request to name a node, a registered type, or a word like
* "section" — and the sentence people actually type names none of the three.
* "I don't like this design. Show me other options." was refused by the gate
* and fell through to the model, which is exactly the wrong place for it: the
* answer is a list of registered components, and a model does not have one.
*
* Safe to run early because this verb decides for itself whether it has a
* subject, and returns null when it does not — so "what are my options for
* this position?" is still nobody's layout request.
*/
if (verb.op === 'options') return planOptions(text, tree, registry, page, focus);
const named = namedType(text, registry, page);
const mentionsUi = has(text, ...UI_WORDS) || Boolean(named);
const anyTarget = walk(tree).some((node) => resolveTarget(tree, text, { registry }).length > 0);
if (!mentionsUi && !anyTarget) return null;
/* A phrase that refers back to what was just changed names its subject
without naming it, so the focus is evidence in its own right. */
const carriesOn = Boolean(focus) && refersBack(text);
if (!mentionsUi && !anyTarget && !carriesOn) return null;
switch (verb.op) {
case 'hide': return planVisibility(text, tree, registry, true);
case 'hide': return planVisibility(text, tree, registry, true, focus);
case 'move': return planMove(text, tree, registry);
case 'replace': return planReplace(text, tree, registry, page);
case 'replace': return planReplace(text, tree, registry, page, focus);
case 'add': return planAdd(text, tree, registry, named, role, page);
case 'layout': return planLayout(text, tree, registry);
case 'present': return planPresent(text, tree, registry);
case 'layout': return planLayout(text, tree, registry, focus);
case 'present': return planPresent(text, tree, registry, focus);
default: return null;
}
}
@@ -183,7 +270,7 @@ export function matchUiEdit(question, {
* while somebody is looking at another one is the failure this exists to
* prevent. Two candidates come back as a question with both named.
*/
function target(text, tree, registry, { exclude = [], preferHidden = false } = {}) {
function target(text, tree, registry, { exclude = [], preferHidden = false, focus = null } = {}) {
const all = resolveTarget(tree, text, { registry })
.filter((node) => !exclude.includes(node.id));
/* "Bring back the timeline" means the hidden one, when a hidden one fits.
@@ -191,6 +278,24 @@ function target(text, tree, registry, { exclude = [], preferHidden = false } = {
const hiddenOnly = all.filter((node) => node.hidden);
const hits = preferHidden && hiddenOnly.length ? hiddenOnly : all;
/**
* Nothing named, but something referred to.
*
* The last resort, and it is fenced on three sides: the phrase has to carry
* a pronoun or a demonstrative, the caller has to have recorded a subject,
* and that subject has to still be on the page. Any of the three missing and
* this is an unknown target exactly as before.
*
* It runs only when the words resolved to nothing, so a request that names a
* section is never quietly redirected to a different one — which would be the
* failure this whole resolver exists to prevent, reintroduced by the back
* door.
*/
if (!hits.length && focus && refersBack(text) && !exclude.includes(focus)) {
const carried = resolveTarget(tree, focus, { registry }).find((node) => node.id === focus);
if (carried) return { node: carried };
}
if (!hits.length) return { kind: 'unknown', phrase: text };
/**
@@ -209,8 +314,8 @@ function target(text, tree, registry, { exclude = [], preferHidden = false } = {
/** Hide or show a node the phrase named. */
function planVisibility(text, tree, registry, hidden) {
const found = target(text, tree, registry);
function planVisibility(text, tree, registry, hidden, focus = null) {
const found = target(text, tree, registry, { focus });
if (found.kind) return found;
return visibilityPlan(found.node, hidden);
}
@@ -356,8 +461,188 @@ function namedType(text, registry, page = null) {
return hit?.entry || null;
}
/**
* What this node could be shown as, and what to call it.
*
* The single place either half of the product asks the question. `planReplace`
* uses it to check one answer and `planOptions` to list them all, so a type
* offered in a list can never be one the same request would refuse — which is
* the bug that two copies of this rule would eventually produce.
*/
function alternatives(node, registry, page) {
const binding = node.data || null;
const shapes = binding?.source ? dataSourceFor(binding.source)?.shapes || [] : null;
const types = registry.replacements(node.type, {
shapes,
page,
seriesKind: kindOfBinding(binding),
seriesBound: Boolean(binding?.series),
/* Whether there is a reading at all — which is what tells the registry
apart "nothing to be wrong about" from "a reading that never said what
it means". */
bound: Boolean(binding),
});
return { binding, types, entries: types.map((type) => registry.get(type)).filter(Boolean) };
}
/**
* "Show me something else" — answered from the registry, never invented.
*
* This is the request that most invites a model to make something up, so it is
* the one most firmly deterministic: the answer is the same list `planReplace`
* would check a single name against, in the same order, produced by the same
* function. Nothing here proposes a design; it reports which registered
* components can honestly draw what this node is already reading.
*
* The target is the harder half, because the sentence rarely names one — "I
* don't like this, show me other options" names nothing at all. So: resolve it
* from the words if the words say; otherwise let the *tree* answer, and only
* when the tree's answer is unambiguous. One node with alternatives is the
* subject. Several is a question back, never a guess.
*/
function planOptions(text, tree, registry, page = null, focus = null) {
/* "An alternative candidate" is not a layout request, whatever else the
sentence contains. Checked first, so no amount of page state can turn it
into one. */
if (qualifiedElsewhere(text, registry)) return null;
const named = target(text, tree, registry, { focus });
const subject = named.kind ? null : named.node;
if (!subject) {
const changeable = walk(tree)
.filter((node) => !node.hidden)
.map((node) => describeNode(node, { registry }))
.filter((node) => node.capabilities.includes('replace'))
.filter((node) => alternatives(node, registry, page).entries.length > 0);
/**
* Nothing on this page could be drawn another way.
*
* Answered by *not* answering. This verb now runs before the
* interface-words gate, so it sees sentences that were never about the
* layout, and the only honest thing to do with one of those, on a page with
* nothing to offer, is leave it to whoever else can answer it. It used to
* say "Nothing on this page can be drawn another way yet." — a true
* sentence, and the wrong reply to a question about candidates.
*/
if (!changeable.length) return null;
/* One thing it could be about is not ambiguity; several is a question. */
if (changeable.length > 1) {
return { kind: 'ambiguous', candidates: changeable.slice(0, 4) };
}
return offer(changeable[0], registry, page);
}
if (!subject.capabilities.includes('replace')) {
return { kind: 'refused', message: `\`${subject.title || subject.label}\` cannot be drawn another way.` };
}
return offer(subject, registry, page);
}
/**
* "A different chart", "another table" — with the nouns read from the registry.
*
* The phrase list above cannot cover this, because the noun a person reaches
* for is the name of a kind of component, and writing those out here would put
* component names back into the language layer — the one thing this file is
* checked for. So the nouns are derived: the last word of every registered
* type's own label, which is "chart" for five of them and "table", "card" or
* "timeline" for the others. A type registered tomorrow is askable for by its
* own noun, tomorrow, with this unchanged.
*/
function asksToRedraw(text, registry) {
return [...redrawNouns(registry)].some((noun) => (
new RegExp(`\\b(another|different|other)\\s+(?:\\w+\\s+)?${noun}\\b`).test(text)
));
}
/**
* The nouns a person can ask for another *of*.
*
* The last word of every registered type's own label — "chart", "table",
* "card", "timeline" — so the vocabulary is the registry's rather than a list
* kept here. Page-bound types are left out on purpose: their labels are the
* names of readings ("Hiring activity", "Position candidates"), and "another
* candidate" is a question about people, not about panels.
*/
function redrawNouns(registry) {
return new Set(
registry.all()
.filter((entry) => !entry.page)
.map((entry) => canon(entry.label).split(' ').pop())
.filter((noun) => noun && noun.length > 2)
);
}
/** The words a bare "another" is about when it is about the interface. */
const REDRAW_WORDS = [
'design', 'designs', 'look', 'looks', 'option', 'options', 'way', 'ways',
'alternative', 'alternatives', 'version', 'versions', 'one', 'ones',
'visualisation', 'visualisations', 'visualization', 'visualizations',
];
/** The words that carry no evidence about what is being asked for. */
const FILLER = new Set([
'the', 'this', 'that', 'for', 'and', 'with', 'from', 'some', 'any', 'you',
'can', 'could', 'are', 'was', 'all', 'out', 'into', 'please', 'give', 'show',
'want', 'like', 'have', 'use', 'about', 'here',
]);
/**
* Is the "other" in this sentence about something that is not the interface?
*
* The one test that keeps "give me an alternative candidate" a question about
* people. Both sentences say "alternative"; the difference is the noun that
* follows it, and it is decided by looking rather than by guessing:
*
* - nothing follows → about the interface ("give me alternatives")
* - one of the interface words → about the interface ("other options")
* - a registered type's noun → about the interface ("a different chart")
* - anything else → not ours, and left entirely alone
*
* A false positive here costs a person a wrong answer about their candidates,
* which is worse than a layout question going unanswered — so the doubtful case
* falls through rather than being claimed.
*/
function qualifiedElsewhere(text, registry) {
const ours = new Set([...REDRAW_WORDS, ...UI_WORDS, ...redrawNouns(registry)]);
const words = text.split(' ');
return words.some((word, i) => {
if (!['other', 'another', 'different', 'alternative', 'alternatives'].includes(word)) {
return false;
}
const following = words.slice(i + 1, i + 3)
.filter((next) => next.length > 2 && !FILLER.has(next));
return following.length > 0 && !ours.has(following[0]);
});
}
/** The list, or the reason there is not one. */
function offer(node, registry, page) {
const { binding, entries } = alternatives(node, registry, page);
if (!entries.length) {
return {
kind: 'refused',
message: `There is no other way to draw ${binding ? labelOfBinding(binding) : node.title || node.label} yet.`,
};
}
return {
kind: 'options',
node,
subject: binding ? labelOfBinding(binding) : node.title || node.label,
/* Four, because a choice a person reads at a glance is a choice they make.
The cap is on what is offered, never on what is possible — the operation
path accepts any registered type the same rule allows. */
options: entries.slice(0, 4).map((entry) => ({
type: entry.type, label: entry.label, summary: entry.summary,
})),
};
}
/** Turn one node into another type. */
function planReplace(text, tree, registry, page = null) {
function planReplace(text, tree, registry, page = null, focus = null) {
/**
* The type asked for is the one after the connector.
*
@@ -385,7 +670,15 @@ function planReplace(text, tree, registry, page = null) {
/* The subject is whatever came before the connector; with no connector, the
sentence minus the type name. */
const subject = split ? text.slice(0, split.index) : text.replace(canon(named.label), ' ');
const found = target(subject, tree, registry);
/**
* "Change **it** back to a line chart."
*
* The half of the sentence before the connector is a pronoun, which names
* nothing and resolves to nothing — so the subject is whatever this
* conversation last changed. That is the ordinary reading of the sentence,
* and producing "I could not find that on this page" instead was the gap.
*/
const found = target(subject, tree, registry, { focus });
if (found.kind) return found;
const node = found.node;
@@ -398,14 +691,26 @@ function planReplace(text, tree, registry, page = null) {
/* The binding decides what it can become. Asking the registry rather than
deciding here is what keeps this free of type knowledge. */
const shapes = node.data ? dataSourceFor(node.data.source)?.shapes || [] : null;
const allowed = registry.replacements(node.type, { shapes, page });
if (!allowed.includes(named.type)) {
const { binding, entries } = alternatives(node, registry, page);
if (!entries.some((entry) => entry.type === named.type)) {
/**
* Refused, with the reason and the way forward.
*
* A bare no is the worst of the three things this could say. The reason
* comes from what the reading *means* — computed once, in `series.js`, so
* the editor's refusal and this one are the same sentence — and the
* alternatives are the very list the next question would produce.
*/
const why = binding
? refuseSeries(named, binding)
: `\`${node.label}\` cannot become a ${named.label}.`;
return {
kind: 'refused',
message: node.data
? `\`${dataSourceLabel(node.data.source)}\` cannot be shown as a ${named.label}.`
: `\`${node.label}\` cannot become a ${named.label}.`,
message: why || (binding?.source
? `\`${dataSourceLabel(binding.source)}\` cannot be shown as a ${named.label}.`
: `\`${node.label}\` cannot become a ${named.label}.`),
node,
alternatives: entries.map((entry) => ({ type: entry.type, label: entry.label })),
};
}
@@ -514,7 +819,7 @@ function namedSource(text, named, answerable = null) {
* putting something back is the request people actually make after trying
* something, and it has to be sayable.
*/
function planPresent(text, tree, registry) {
function planPresent(text, tree, registry, focus = null) {
const wants = {};
if (has(text, 'compact', 'denser', 'tighter')) wants.density = 'compact';
if (has(text, 'comfortable', 'spacious', 'roomier')) wants.density = 'comfortable';
@@ -523,7 +828,7 @@ function planPresent(text, tree, registry) {
if (has(text, 'default', 'reset', 'normal')) wants.variant = 'default';
if (!Object.keys(wants).length) return { kind: 'unknown', phrase: text };
const found = target(text, tree, registry);
const found = target(text, tree, registry, { focus });
if (found.kind) return found;
const node = found.node;
@@ -556,13 +861,13 @@ function planPresent(text, tree, registry) {
}
/** Column counts. */
function planLayout(text, tree, registry) {
function planLayout(text, tree, registry, focus = null) {
const digit = /(\d+)\s*(?:column|col)/.exec(text)?.[1];
const word = Object.keys(NUMBER_WORDS).find((w) => has(text, `${w} column`));
const columns = Number(digit) || NUMBER_WORDS[word] || (has(text, 'side by side', 'two up') ? 2 : null);
if (!columns) return { kind: 'unknown', phrase: text };
const found = target(text, tree, registry);
const found = target(text, tree, registry, { focus });
if (found.kind) return found;
const node = found.node;

View File

@@ -102,6 +102,30 @@ export const VARIANT_VALUES = ['default', 'subtle', 'emphasis'];
/** How tightly a node is packed. `comfortable` is today's spacing, unchanged. */
export const DENSITY_VALUES = ['comfortable', 'compact'];
/**
* What a numeric series *means*, as a closed vocabulary.
*
* Shape and meaning are different questions and the engine had only the first.
* `flow` says a reading is a labelled numeric series; it does not say whether
* the labels are days, funnel stages or disjoint categories — and that is
* exactly the difference between a pie chart that is true and one that is a
* lie. `SECTION_TYPES` even admits it: a flow is "a sequence of stages **or**
* periods".
*
* - `periodic` points ordered in time. The order carries the meaning and
* the values do not add up to a whole.
* - `cumulative` a funnel: each step is a subset of the one before it, so
* the steps overlap and summing them counts people twice.
* - `parts` disjoint categories that together make up one total. The
* only meaning for which "share of the whole" is true.
*
* A type declares which of these it can honestly draw (`seriesKinds`); a
* binding is asked what it is. Neither is inferred from a component name, and a
* binding that cannot say stays unknown — which leaves it exactly as permissive
* as it was before this vocabulary existed, rather than guessing.
*/
export const SERIES_KINDS = ['periodic', 'cumulative', 'parts'];
/**
* A node, with every field settled.
*
@@ -158,6 +182,18 @@ function normalizeBinding(value) {
return source ? { source, params: {} } : null;
}
if (typeof value !== 'object' || Array.isArray(value)) return null;
/**
* A page's own series, rather than a skill's reading.
*
* The second kind of binding, and the reason built-in charts can be changed
* at all. A page section's figures are not in the skill data vocabulary —
* they are derived by the page from what it already loaded — so a node that
* draws them can only name them, the same way a skill node names a source.
* Which one a binding is, is decided by which key it carries; both are
* resolved through a registry and neither can carry a value.
*/
const series = String(value.series ?? '').trim();
if (series) return { series, params: plainObject(value.params) };
const source = String(value.source ?? '').trim();
if (!source) return null;
return { source, params: plainObject(value.params) };

View File

@@ -25,6 +25,7 @@ import {
cloneNode, findNode, locate, makeNode, mapChildren, mapNode,
} from './node';
import { nodeRegistry } from './registry';
import { refuseSeries } from './series';
import { takenIds, validateTree } from './validate';
/** The operation names the engine understands. Anything else is refused by name. */
@@ -355,7 +356,28 @@ const BUILDERS = {
return fail(nodes, `\`${entry.label}\` cannot show ${label}.`);
}
}
if (!binding?.source && entry.dataRequired) {
/**
* A page's own series is compatible with the types that draw one.
*
* The structural question for this kind of binding is not "which shapes"
* — a page series is not in the shape vocabulary and should not be — it is
* whether the candidate draws a series at all, which is what a non-empty
* `seriesKinds` says.
*/
if (binding?.series && !entry.seriesKinds.length) {
return fail(nodes, `\`${entry.label}\` cannot draw a series.`);
}
/**
* And the semantic question, for either kind of binding.
*
* Refused by what the reading *is*, not by what the component is called, so
* the sentence a person gets back tells them why rather than merely that.
* Silence — an unknown kind — is not a refusal.
*/
const untrue = binding ? refuseSeries(entry, binding) : null;
if (untrue && entry.seriesKinds.length) return fail(nodes, untrue);
if (!binding?.source && !binding?.series && entry.dataRequired) {
return fail(nodes, `\`${entry.label}\` needs a reading, and \`${before.id}\` has none.`);
}

View File

@@ -19,7 +19,8 @@
*/
import {
DENSITY_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_CAPABILITIES, NODE_ID_PATTERN, VARIANT_VALUES,
DENSITY_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_CAPABILITIES, NODE_ID_PATTERN, SERIES_KINDS,
VARIANT_VALUES,
} from './node';
/**
@@ -87,6 +88,7 @@ export class NodeTypeRegistry {
};
refuseUnknown('variant', definition.variants, VARIANT_VALUES);
refuseUnknown('density', definition.densities, DENSITY_VALUES);
refuseUnknown('series kind', definition.seriesKinds, SERIES_KINDS);
/* A type offered by a picker and unable to be removed is a dead end: a
person creates a node and has no way to take it back. Said here rather
@@ -167,6 +169,22 @@ export class NodeTypeRegistry {
variants: Object.freeze([...(definition.variants || [])]),
densities: Object.freeze([...(definition.densities || [])]),
/**
* The series *meanings* this type can honestly draw.
*
* Empty means two things at once, and both are the conservative reading:
* this type is not a visualization of a series, and it makes no claim
* about meaning. So a section registered before this field existed is
* unaffected — it is neither offered as a chart nor vetoed as one.
*
* A non-empty list is a promise. `pie-chart` says `parts` and nothing
* else, and that single word is what refuses to draw twelve days of
* hiring as twelve slices of a whole. The alternative — letting the
* shape vocabulary decide — cannot express it: `flow` is, in its own
* words, "a sequence of stages *or* periods".
*/
seriesKinds: Object.freeze([...(definition.seriesKinds || [])]),
/**
* The page this type belongs to, or `null` for one that belongs anywhere.
*
@@ -303,7 +321,19 @@ export class NodeTypeRegistry {
* coupling it to data sources would make a UI type impossible to register
* without one.
*/
replacements(type, { shapes = null, page = null } = {}) {
replacements(type, {
shapes = null, page = null, seriesKind = null, seriesBound = false,
/**
* Whether this node reads anything at all.
*
* Separate from `seriesKind`, because "no binding" and "a binding that
* never said what it means" are different situations with different right
* answers. An unbound node has nothing to be untrue about; a bound one
* whose meaning was never established is exactly the case the semantic
* veto exists for.
*/
bound = false,
} = {}) {
const current = this.get(type);
if (!current) return [];
@@ -325,12 +355,47 @@ export class NodeTypeRegistry {
* in every page's "Show as" list.
*/
.filter((entry) => this.offersChild(null, entry.type, { page }))
/**
* The semantic veto, applied to every binding.
*
* A type that has said which meanings it can draw may only draw those.
* This is the one filter that is about truth rather than about structure,
* and it is why "show hiring activity as a pie chart" is refused while
* "as a bar chart" is not — both are structurally possible and only one
* of them is honest. A kind of `null` is "not known", and vetoes nothing.
*/
.filter((entry) => {
/* A type that claims no meanings makes no claim to be wrong about. */
if (!entry.seriesKinds.length) return true;
/* Nothing bound yet: the binding decides this, and there is not one. */
if (!bound) return true;
/**
* Bound, and only offerable if the meaning is known and drawable.
*
* The `!seriesKind` case is the one that changed: an undeclared
* meaning used to fall through as "no objection", which is how a card
* reading `Recent hires` came to be offered a pie chart. Not knowing
* is not permission — see `refuseSeries`, which produces the sentence
* a person is given when they ask for one of these by name.
*/
return Boolean(seriesKind) && entry.seriesKinds.includes(seriesKind);
})
.filter((entry) => (
/* A type that reads no data can only be swapped for another that reads
none — a divider is not an alternative rendering of a chart. */
wanted.length
? entry.dataShapes.some((shape) => wanted.includes(shape))
: entry.dataShapes.length === 0
/**
* The structural half, in the currency of whichever binding this is.
*
* A node bound to a page's own series is compatible with the types that
* draw a series — which is what `seriesKinds` being non-empty means.
* A node bound to a skill reading is compared shape to shape, as it has
* always been. A type that reads no data can only be swapped for
* another that reads none: a divider is not an alternative rendering of
* a chart.
*/
seriesBound
? entry.seriesKinds.length > 0
: (wanted.length
? entry.dataShapes.some((shape) => wanted.includes(shape))
: entry.dataShapes.length === 0)
))
.map((entry) => entry.type);
}

300
src/lib/ui/series.js Normal file
View File

@@ -0,0 +1,300 @@
/**
* What a series is, what it means, and where a page publishes one.
*
* Two things live here, and they are the two halves of "can this reading be
* drawn that way".
*
* **The series registry.** A page derives figures from the records it has
* already loaded — Control Center buckets applications, screenings, interviews
* and hires by day — and those figures are not in the skill data vocabulary and
* should not be: nothing outside that page can compute them. So a page declares
* them here, by name, with a pure `read` that takes the bag the page publishes
* and returns rows. A node then *names* the series, exactly as a skill node
* names a source. **A binding never carries values**, so there is still nowhere
* for an invented figure to live.
*
* **The meaning.** `dataShapes` answers whether a component can draw a shape;
* it cannot answer whether doing so would be true. A pie of a time series is
* structurally fine and semantically a lie — it throws the timeline away and
* presents days as slices of a whole. `SERIES_KINDS` is the missing half, and
* `kindOfBinding` is where a binding is asked which one it is.
*
* The rule for not knowing is deliberate, and it is strict: **an unknown kind
* is a refusal.** A component that has declared which meanings it can draw may
* only draw a meaning that was actually declared — by a page's series, by a
* reading in the data-source vocabulary, or by the binding's own periods. This
* is the one place the system could otherwise produce something structurally
* valid and factually untrue, and silence is not evidence that it would be
* true. Declaring a meaning is one field; guessing one is not offered.
*
* Components that declare no meanings at all — a table, a list, a card — are
* untouched by any of this. They make no claim about what their rows mean, so
* there is nothing for them to be wrong about.
*/
import { SERIES_KINDS } from './node';
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
export { SERIES_KINDS };
/** Why a kind is what it is, in words a refusal can use. */
const KIND_REASON = {
periodic: 'it is ordered in time, and that order is the reading',
cumulative: 'each step is part of the one before it, so the steps overlap',
parts: 'it is a set of parts that make up one whole',
};
/** @type {Map<string, any>} */
const providers = new Map();
/** A series id looks like a node id: lower-case, dashes, and a dotted namespace. */
const SERIES_ID_PATTERN = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/;
/**
* Declare a series a page publishes.
*
* Called at module scope beside the page that computes it, the same way a page
* registers its composition — so a series and the page that can answer for it
* are deployed together or not at all.
*
* Re-registering replaces, because the dev server re-runs the module on every
* save and throwing there would make the file uneditable.
*/
/**
* A control a series publishes, checked at registration.
*
* The missing half of "replacing a visualization must not remove what was
* there". A page's built-in chart often ships with a control that governs the
* reading itself — Control Center's 7D/30D/90D toggle changes *which rows the
* series returns*, not how they are drawn — and replacing the component threw
* it away, because the control lived inside the component.
*
* Declaring it on the **series** is what fixes that generically: the control
* belongs to the reading, so every component that can draw the reading draws
* the control too, and nothing anywhere names a range, a page or a chart. A
* series that publishes none is unaffected, and no control is ever invented —
* `read` and `write` are the page's own, so a page that stops publishing the
* state stops publishing the control with it.
*/
function normalizeControl(id, control) {
const controlId = String(control?.id ?? '').trim();
if (!controlId) throw new Error(`registerSeries: \`${id}\` declares a control with no \`id\`.`);
const options = (control.options || []).map((option) => ({
value: String(option?.value ?? '').trim(),
label: String(option?.label ?? '').trim(),
}));
if (!options.length) {
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` needs at least one option.`);
}
const blank = options.find((option) => !option.value || !option.label);
if (blank) {
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` has an option with no value or label.`);
}
if (typeof control.read !== 'function' || typeof control.write !== 'function') {
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` needs a \`read\` and a \`write\`.`);
}
return Object.freeze({
id: controlId,
label: String(control.label ?? controlId).trim(),
options: Object.freeze(options.map(Object.freeze)),
read: control.read,
write: control.write,
});
}
export function registerSeries(definition) {
const id = String(definition?.id ?? '').trim();
if (!id) throw new Error('registerSeries: a series needs an `id`.');
if (!SERIES_ID_PATTERN.test(id)) {
throw new Error(`registerSeries: \`${id}\` must be lower-case letters, numbers, dashes and dots.`);
}
const kind = String(definition.kind ?? '').trim();
if (!SERIES_KINDS.includes(kind)) {
throw new Error(
`registerSeries: \`${id}\` declares unknown kind \`${kind || '(none)'}\`. `
+ `Known: ${SERIES_KINDS.join(', ')}.`
);
}
if (typeof definition.read !== 'function') {
throw new Error(`registerSeries: \`${id}\` needs a \`read(context)\`.`);
}
const measures = (definition.measures || []).map((measure) => ({
key: String(measure?.key ?? '').trim(),
label: String(measure?.label ?? '').trim(),
}));
if (!measures.length) {
throw new Error(`registerSeries: \`${id}\` needs at least one measure.`);
}
const blank = measures.find((measure) => !measure.key || !measure.label);
if (blank) throw new Error(`registerSeries: \`${id}\` has a measure with no key or label.`);
const entry = Object.freeze({
id,
label: String(definition.label ?? id).trim(),
kind,
measures: Object.freeze(measures.map(Object.freeze)),
/* The key on each row that names the point — the x axis, or the slice. */
labelKey: String(definition.labelKey ?? 'label').trim() || 'label',
read: definition.read,
emptyNote: String(definition.emptyNote ?? 'Nothing to chart yet.').trim(),
/* Optional, and absent for every series that does not publish one. */
control: definition.control ? normalizeControl(id, definition.control) : null,
});
providers.set(id, entry);
return entry;
}
/** The registration, or null. */
export const seriesFor = (id) => providers.get(String(id ?? '').trim()) || null;
/** Every registered series. Used by tests and by anything that has to list them. */
export const listSeries = () => [...providers.values()];
/** Forget everything. Tests only. */
export const resetSeries = () => providers.clear();
/**
* What this binding means, or null for a binding that cannot say.
*
* Three answers, in order of how much they actually know:
*
* 1. A registered series declared its kind. That is a fact its author wrote.
* 2. A reading bound to time periods is periodic. Also a fact — `periods` is
* in the closed option vocabulary and the resolver buckets by it.
* 3. Otherwise unknown, and unknown vetoes nothing.
*/
export function kindOfBinding(binding) {
if (!binding) return null;
if (binding.series) return seriesFor(binding.series)?.kind || null;
if (binding.source) {
if (binding.params?.periods?.length) return 'periodic';
return dataSourceFor(binding.source)?.series || null;
}
return null;
}
/** What to call this binding in a sentence. */
export function labelOfBinding(binding) {
if (!binding) return 'this';
if (binding.series) return seriesFor(binding.series)?.label || binding.series;
if (binding.source) return dataSourceLabel(binding.source) || binding.source;
return 'this';
}
/**
* Why a type may not draw this binding — one sentence, or null if it may.
*
* Written here rather than at the call sites so the editor and the conversation
* refuse in the same words, and so the reason is about the *data* rather than
* about the component. "A pie chart cannot show Hiring activity" tells nobody
* anything; saying that the reading is ordered in time does.
*/
export function refuseSeries(entry, binding) {
if (!entry.seriesKinds.length) {
return `\`${entry.label}\` cannot draw ${labelOfBinding(binding)}.`;
}
const kind = kindOfBinding(binding);
/**
* Not knowing is a refusal, not a permission.
*
* This used to be the other way round — an unknown kind vetoed nothing — and
* the consequence was visible in the product: a card reading `Recent hires`
* was offered a pie chart, because nothing had ever said what those figures
* mean and silence was read as consent. A component that has declared which
* meanings it can honestly draw cannot draw one that was never declared, so
* the honest answer is no, with the reason.
*
* Saying yes here is the only way this system can produce a chart that is
* structurally valid and factually a lie, which is why the default is the
* strict one. Establishing compatibility is one field on the reading — see
* `series` in the data-source vocabulary — and is deliberately the author's
* statement rather than a guess made here from a label or a shape.
*/
if (!kind) {
return (
`\`${entry.label}\` cannot show ${labelOfBinding(binding)}: `
+ 'it does not say what its figures mean, so there is no way to know '
+ 'that drawing them this way would be true.'
);
}
if (entry.seriesKinds.includes(kind)) return null;
return (
`\`${entry.label}\` cannot show ${labelOfBinding(binding)}: `
+ `${KIND_REASON[kind]}.`
);
}
/**
* The control this binding publishes, resolved against the page, or null.
*
* Two facts have to line up before a control exists: the series declared one,
* and the page is actually publishing the state it names. Either missing means
* no control — nothing is drawn from a default, and nothing is fabricated to
* fill the gap a replaced component left behind.
*/
export function controlOfBinding(binding, context = {}) {
const entry = binding?.series ? seriesFor(binding.series) : null;
const control = entry?.control;
if (!control) return null;
const value = control.read(context || {});
if (value == null) return null;
return {
id: control.id,
label: control.label,
options: control.options,
value,
set: (next) => control.write(context || {}, next),
};
}
/**
* The rows a binding resolves to, in the one shape every visualization reads.
*
* `{ rows, measures, emptyNote }` — rows carry a label and one number per
* measure, so a single-measure skill reading and a four-measure page series are
* the same object by the time a chart sees them. Nothing is aggregated,
* re-bucketed or re-ordered on the way: a chart draws what the page or the
* resolver already computed, and a transformation that changed the meaning
* would have to be written somewhere, and there is nowhere.
*
* `resolve` is passed in rather than imported so this stays free of React and
* of the skill resolver — the component knows how to get a skill reading, and
* this knows what to do with either kind.
*/
export function readSeries(binding, { context = {}, resolveSource = null } = {}) {
const empty = { rows: [], measures: [], emptyNote: 'Nothing to chart yet.', label: '' };
if (!binding) return empty;
if (binding.series) {
const entry = seriesFor(binding.series);
if (!entry) return empty;
const raw = entry.read(context || {}) || [];
const rows = (Array.isArray(raw) ? raw : []).map((row, i) => {
const point = { id: String(row?.id ?? i), label: String(row?.[entry.labelKey] ?? '') };
for (const measure of entry.measures) point[measure.key] = Number(row?.[measure.key]) || 0;
return point;
}).filter((row) => row.label);
return { rows, measures: entry.measures, emptyNote: entry.emptyNote, label: entry.label };
}
if (!binding.source || typeof resolveSource !== 'function') return empty;
const data = resolveSource(binding) || {};
const rows = (data.steps || data.items || []).map((step, i) => ({
id: String(step?.id ?? i),
label: String(step?.label || step?.title || ''),
value: Number(step?.value) || 0,
})).filter((row) => row.label);
return {
rows,
measures: [{ key: 'value', label: dataSourceLabel(binding.source) || 'Value' }],
emptyNote: data.emptyNote || 'Nothing to chart yet.',
label: dataSourceLabel(binding.source) || binding.source,
};
}

View File

@@ -27,6 +27,7 @@ import {
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, dataSourceFor, sourceSupportsOption,
sourceSupportsShape,
} from '@/lib/skills/surfaces';
import { refuseSeries, seriesFor } from './series';
import {
ALIGN_VALUES, DENSITY_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_ID_PATTERN,
PRESENTATION_KEYS, SPACING_VALUES, VARIANT_VALUES, walk,
@@ -303,6 +304,31 @@ function validateBinding(node, entry) {
return problems;
}
/**
* A page's own series.
*
* Checked against the series registry rather than the data-source vocabulary,
* because it is a different closed table — but the gate is the same one and
* for the same reason: a binding may only *name* something already
* registered, so there is still nowhere for a fabricated figure to live. The
* meaning is checked too, so a stored patch that turned a time series into a
* pie is refused on load rather than drawn.
*/
if (binding.series) {
const series = seriesFor(binding.series);
if (!series) {
problems.push(problem(at, `Unsupported series: ${binding.series}.`));
return problems;
}
if (!entry.seriesKinds.length) {
problems.push(problem(at, `\`${entry.type}\` does not draw a series.`));
return problems;
}
const untrue = refuseSeries(entry, binding);
if (untrue) problems.push(problem(at, untrue));
return problems;
}
if (!entry.dataShapes.length) {
problems.push(problem(at, `\`${entry.type}\` does not read data, so it cannot take a source.`));
return problems;
@@ -329,6 +355,19 @@ function validateBinding(node, entry) {
));
}
/**
* And the semantic half, for a skill reading as well as for a page series.
*
* The same call, the same sentence, one rule — so a stored patch that binds a
* component to a reading it cannot honestly draw is refused on load exactly
* as the operation that would have created it is refused, rather than being
* replayed into the tree because nobody re-asked the question here.
*/
if (entry.seriesKinds.length) {
const untrue = refuseSeries(entry, binding);
if (untrue) problems.push(problem(at, untrue));
}
const params = binding.params || {};
if (params.periods != null) {

View File

@@ -4,7 +4,7 @@ import { recommendJobs, recommendNextCourse } from '@/lib/krowScore';
import TalentHero from '@/components/krow/talent/TalentHero';
import IncreaseMarketValue from '@/components/krow/talent/IncreaseMarketValue';
import CareerGrowth from '@/components/krow/talent/CareerGrowth';
import MarketOffers from '@/components/krow/talent/MarketOffers';
import Opportunities from '@/components/krow/talent/Opportunities';
import ExperienceNetwork from '@/components/krow/talent/ExperienceNetwork';
import { Loader2 } from 'lucide-react';
@@ -25,7 +25,7 @@ export default function EmployeeDashboard() {
return (
<div className="space-y-6">
<TalentHero profile={profile} jobRecs={jobRecs} nextCourse={nextCourse} openOwliver={openOwliver} />
<MarketOffers profile={profile} jobRecs={jobRecs} />
<Opportunities jobRecs={jobRecs} />
<IncreaseMarketValue courses={courses} />
<CareerGrowth profile={profile} />
<ExperienceNetwork profile={profile} />

View File

@@ -0,0 +1,208 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft, MapPin, Wallet, Sparkles, CheckCircle2, Award,
Briefcase, Clock, Loader2, Compass,
} from 'lucide-react';
import { useWorkerProfile, useJobPostings } from '@/lib/krowHooks';
import { computeJobMatch } from '@/lib/krowScore';
/**
* OpportunityDetail — the full-page Employee job detail.
*
* A dedicated route (/opportunities/:id), NOT a drawer/sheet/modal. It continues
* the Opportunities grid: the same title-first identity, the same match, and an
* Apply action that hands off to the existing /apply?job=<id> flow (which runs
* the AI interview). It reuses the existing matching — useWorkerProfile,
* useJobPostings and computeJobMatch — and changes none of it.
*
* COMPANY PRIVACY: never renders job.company. Only title, role_category,
* location, pay, status, description/responsibilities/qualifications and the
* match signals are shown.
*/
/* The skills the profile brings that this role asks for — the same
case-insensitive overlap computeJobMatch scores, shown here for the reader
rather than recomputed into a number. Read-only; the algorithm is untouched. */
function matchedSkills(profile, job) {
const have = (profile?.skills || []).map((s) => String(s).toLowerCase());
const want = job?.qualifications || [];
return want.filter((q) => {
const ql = String(q).toLowerCase();
return have.some((ps) => ql.includes(ps) || ps.includes(ql));
});
}
function matchedCerts(profile, job) {
const have = (profile?.earned_badges || []).map((b) => String(b?.name || '').toLowerCase()).filter(Boolean);
const want = job?.certifications_required || [];
return want.filter((c) => {
const cl = String(c).toLowerCase();
return have.some((pc) => cl.includes(pc) || pc.includes(cl));
});
}
function Section({ title, children }) {
return (
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
<h2 className="text-[15px] font-bold text-[#0F172A] mb-3">{title}</h2>
{children}
</div>
);
}
export default function OpportunityDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { data: profile, isLoading: loadingProfile } = useWorkerProfile();
const { data: postings = [], isLoading: loadingJobs } = useJobPostings();
if (loadingProfile || loadingJobs) {
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0838E0]" /></div>;
}
const job = postings.find((p) => p.id === id);
if (!job) {
return (
<div className="max-w-2xl mx-auto text-center py-20">
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3">
<Compass className="w-6 h-6" />
</div>
<p className="text-[15px] font-semibold text-[#0F172A]">This opportunity is no longer available.</p>
<button
onClick={() => navigate('/employee')}
className="mt-5 inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0838E0] hover:text-[#062BAF]"
>
<ArrowLeft className="w-4 h-4" /> Back to Opportunities
</button>
</div>
);
}
const match = computeJobMatch(profile, job);
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : null;
const isHiring = job.status === 'active';
const skills = matchedSkills(profile, job);
const certs = matchedCerts(profile, job);
const exp = profile?.experience_years || 0;
const reqExp = job.min_experience_years || 0;
const meetsExp = exp >= reqExp;
return (
<div className="max-w-3xl mx-auto space-y-5 pb-10">
<button
onClick={() => navigate('/employee')}
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#6B7280] hover:text-[#111827]"
>
<ArrowLeft className="w-4 h-4" /> Back to Opportunities
</button>
{/* Header — title-first identity, no company name. */}
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center shrink-0">
<Briefcase className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-[22px] font-bold text-[#0F172A] leading-tight">{job.title}</h1>
{isHiring && (
<span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full">Hiring</span>
)}
</div>
{job.role_category && <p className="mt-1 text-[14px] text-[#6B7280] capitalize">{job.role_category}</p>}
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
<span className="inline-flex items-center gap-1.5 text-[14px] font-bold text-[#0838E0]">
<Sparkles className="w-4 h-4" /> {match}% match
</span>
<span className="inline-flex items-center gap-1.5 text-[13px] text-[#6B7280]">
<MapPin className="w-4 h-4 text-[#9CA3AF]" /> {job.location || 'Remote'}
</span>
{pay && (
<span className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#111827]">
<Wallet className="w-4 h-4 text-[#9CA3AF]" /> {pay}
</span>
)}
</div>
</div>
</div>
</div>
{/* About this opportunity */}
<Section title="About this opportunity">
{job.description ? (
<p className="text-[13px] text-[#6B7280] whitespace-pre-wrap leading-relaxed">{job.description}</p>
) : (
<p className="text-[13px] text-[#9CA3AF]">No description provided for this opportunity yet.</p>
)}
{job.responsibilities?.length > 0 && (
<div className="mt-4">
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">What you'll do</p>
<ul className="list-disc pl-5 space-y-1 text-[13px] text-[#6B7280]">
{job.responsibilities.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{job.qualifications?.length > 0 && (
<div className="mt-4">
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">What they're looking for</p>
<ul className="list-disc pl-5 space-y-1 text-[13px] text-[#6B7280]">
{job.qualifications.map((q, i) => <li key={i}>{q}</li>)}
</ul>
</div>
)}
</Section>
{/* Why you're a match */}
<Section title="Why you're a match">
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden">
<div className="h-full rounded-full bg-[#0838E0]" style={{ width: `${match}%` }} />
</div>
<span className="text-[14px] font-bold text-[#0838E0]">{match}%</span>
</div>
<ul className="space-y-2.5">
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
<Clock className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<span>
{exp} yr{exp === 1 ? '' : 's'} of experience
{reqExp > 0 && <> · role asks for {reqExp}+ {meetsExp ? '— you qualify' : ''}</>}
</span>
</li>
{skills.length > 0 && (
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
<CheckCircle2 className="w-4 h-4 text-[#16A34A] mt-0.5 shrink-0" />
<span>Your skills match: {skills.join(', ')}</span>
</li>
)}
{certs.length > 0 && (
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
<Award className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<span>Certifications you hold: {certs.join(', ')}</span>
</li>
)}
{skills.length === 0 && certs.length === 0 && (
<li className="flex items-start gap-2 text-[13px] text-[#6B7280]">
<Sparkles className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<span>You showed up here because your profile aligns with this role. Add skills to strengthen the match.</span>
</li>
)}
</ul>
</Section>
{/* Apply */}
<div className="flex justify-end">
<button
onClick={() => navigate(`/apply?job=${job.id}`)}
className="inline-flex items-center justify-center gap-2 h-11 px-8 rounded-full bg-[#0838E0] text-white text-[14px] font-semibold hover:bg-[#062BAF] transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
>
Apply <Sparkles className="w-4 h-4" />
</button>
</div>
</div>
);
}

View File

@@ -126,7 +126,7 @@ function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
{shipped
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Remove</>}
</DropdownMenuItem>
</>
)}
@@ -310,7 +310,7 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
{shipped
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Remove</>}
</DropdownMenuItem>
</>
)}
@@ -330,7 +330,12 @@ export default function AdminWorkspaceAgents() {
} = useAgents();
const [query, setQuery] = useState('');
const [status, setStatus] = useState('all');
/* The default view is the agents in service. Removing an agent archives it,
and an archived agent that stayed in the default list would make Remove
look like it had not worked — while hiding it anywhere but a tab the
reader can open would make it look destroyed. Archived is one click away
and is where restoring happens. */
const [status, setStatus] = useState('active');
const [view, setView] = useState(() => {
try {
return localStorage.getItem('krow_agents_view') || 'list';
@@ -351,16 +356,35 @@ export default function AdminWorkspaceAgents() {
const visible = useMemo(() => {
const found = searchAgents(agents, query);
return status === 'all' ? found : found.filter((a) => a.status === status);
if (status === 'active') return found.filter((a) => a.status !== 'archived');
return found.filter((a) => a.status === status);
}, [agents, query, status]);
const activeCount = agents.filter((a) => a.status !== 'archived').length;
const publishedCount = agents.filter((a) => a.status === 'published').length;
const draftCount = agents.filter((a) => a.status === 'draft').length;
const archivedCount = agents.filter((a) => a.status === 'archived').length;
/** Runs a lifecycle action and reports honestly when it is refused. */
/**
* Runs a lifecycle action and reports honestly when it is refused.
*
* `remove` resolves to two different operations, and the difference is the
* whole of what makes removal safe:
*
* authored agent → archive. The definition is kept, under the same id,
* with its instructions, tools and skills untouched. It
* leaves the default list and can be restored.
* shipped agent → remove, which deletes THIS ACCOUNT'S override row and
* nothing else. The product's own definition takes over
* again — that is the "Revert to shipped" wording the
* menu already uses, and it destroys no shipped agent.
*
* Resolved here rather than in the menu so both the card and the table row
* cannot drift apart on what Remove means.
*/
const run = async (action, agent) => {
const fn = { publish, republish: publish, archive, restore, duplicate, remove }[action];
const resolved = action === 'remove' && !isShipped(agent.id) ? 'archive' : action;
const fn = { publish, republish: publish, archive, restore, duplicate, remove }[resolved];
const result = await fn(agent.id);
if (result?.conflict) toast.error(result.conflict.message);
else if (result?.error) toast.error(result.error);
@@ -368,7 +392,9 @@ export default function AdminWorkspaceAgents() {
};
const onAction = (action, agent) => {
/* Destructive and irreversible-looking actions confirm; the rest run. */
/* Anything that takes an agent out of service confirms first, even though
none of it is destructive — a reader should choose to stop an agent
answering, not discover it. The rest run. */
if (action === 'remove' || action === 'archive') {
setConfirming({ action, agent });
return;
@@ -410,15 +436,15 @@ export default function AdminWorkspaceAgents() {
<div className="inline-flex items-center rounded-xl border border-border/80 bg-surface-subtle/80 p-1 text-caption">
<button
type="button"
onClick={() => setStatus('all')}
onClick={() => setStatus('active')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'all'
status === 'active'
? 'bg-surface text-ink-1 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
All <span className="text-ink-4 font-normal">({agents.length})</span>
Active <span className="text-ink-4 font-normal">({activeCount})</span>
</button>
<button
type="button"
@@ -446,20 +472,26 @@ export default function AdminWorkspaceAgents() {
Drafts <span className="text-ink-4 font-normal">({draftCount})</span>
</button>
)}
{archivedCount > 0 && (
<button
type="button"
onClick={() => setStatus('archived')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'archived'
? 'bg-surface text-amber-700 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
Archived <span className="text-ink-4 font-normal">({archivedCount})</span>
</button>
)}
{/* Always rendered, empty or not. Archived is where removing an
agent puts it and where restoring it happens, and the remove
confirmation says so in as many words — a tab that appears
only once something is already in it tells a reader where
their agent went strictly after they needed to know. The
count answers "is anything in here?" without hiding the
answer. Drafts stays conditional: an empty Drafts tab
teaches nothing, because nothing is ever sent there. */}
<button
type="button"
onClick={() => setStatus('archived')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'archived'
? 'bg-surface text-amber-700 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
Archived <span className="text-ink-4 font-normal">({archivedCount})</span>
</button>
</div>
</div>
@@ -546,16 +578,23 @@ export default function AdminWorkspaceAgents() {
title={
confirming?.action === 'archive'
? `Archive ${confirming?.agent.name}?`
: isShipped(confirming?.agent?.id) ? 'Revert to the shipped definition?' : `Delete ${confirming?.agent.name}?`
: isShipped(confirming?.agent?.id) ? 'Revert to the shipped definition?' : 'Remove agent?'
}
description={
confirming?.action === 'archive'
? 'It stops answering and disappears from the switcher. Its definition is kept, and it can be restored as a draft.'
: isShipped(confirming?.agent?.id)
? 'Your changes to this agent are discarded and the shipped definition takes over again.'
: 'This definition is removed. It cannot be recovered.'
: 'This agent will be removed from the active Agents list. Its configuration and '
+ 'skills will be preserved and it can be restored later from Archived.'
}
confirmLabel={
confirming?.action === 'archive'
? 'Archive'
/* Reverting a shipped agent is not a removal and must not read as
one: the shipped definition takes over, nothing is destroyed. */
: isShipped(confirming?.agent?.id) ? 'Confirm' : 'Remove Agent'
}
confirmLabel={confirming?.action === 'archive' ? 'Archive' : 'Confirm'}
busy={saving}
onConfirm={async () => {
const pending = confirming;

View File

@@ -366,6 +366,7 @@ function ActionQueue({ items }) {
/* ── Page ───────────────────────────────────────────────────────────────── */
import { registerNodeType } from '@/lib/ui/registry';
import { registerSeries } from '@/lib/ui/series';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
@@ -830,6 +831,57 @@ registerNodeType({
wrap: true,
});
/**
* The figures behind this page's hiring chart, named so something else can draw
* them.
*
* The page already computed this array — it is the same `activitySeries` the
* composed chart above reads, taken from the same context, unaggregated and
* unreordered. Declaring it here is what lets a person say "show this as a bar
* chart" and get *these* numbers rather than a new empty panel, and it is the
* only thing that had to be added to make a built-in chart changeable at all.
*
* `periodic` is the honest kind: these are days in order. It is also what
* refuses the pie.
*/
registerSeries({
id: 'control-center.hiring-activity',
label: 'Hiring activity',
kind: 'periodic',
measures: [
{ key: 'applications', label: 'Applications' },
{ key: 'screened', label: 'AI Screened' },
{ key: 'interviews', label: 'Interviews' },
{ key: 'hires', label: 'Hires' },
],
read: (context) => context?.activitySeries || [],
emptyNote: 'No applications yet.',
/**
* The range control, declared on the reading rather than on the component.
*
* It is the reason "show this as a bar chart" used to be a downgrade: the
* 7D/30D/90D toggle lived inside this page's own chart section, so replacing
* the section removed a control the person had been using — and the figures
* it governs are the very figures the replacement draws.
*
* Declaring it here moves it to where it actually belongs. The control does
* not change how the series is drawn; it changes which rows the series *is*,
* which is a property of the reading. Every visualization draws it because
* every visualization asks the binding for it, and none of them knows what a
* range is. `options` is the same `RANGES` table the built-in section reads,
* so the two cannot drift apart.
*/
control: {
id: 'range',
label: 'Hiring activity range',
options: Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label })),
/* The page's own state, both ways. Nothing is stored here, and a page that
stops publishing `range` stops publishing the control with it. */
read: (context) => context?.range ?? null,
write: (context, value) => context?.setRange?.(value),
},
});
registerNodeType({
type: 'cc-activity',
/* This page's own section: it reads what this page publishes, so it belongs
@@ -837,7 +889,17 @@ registerNodeType({
page: 'control-center',
label: 'Hiring activity',
component: CCActivity,
capabilities: SECTION,
/**
* The one section on this page that can become something else.
*
* It draws a series, it says which series, and it says what that series
* means — which is the whole of what the engine needs to offer a bar, a line
* or an area in its place and to refuse a pie. Nothing about charts is
* written here: the candidates are computed by the registry from these three
* facts.
*/
capabilities: [...SECTION, 'replace'],
seriesKinds: ['periodic'],
wrap: true,
});
@@ -899,7 +961,7 @@ registerNodeType({
registerPageComposition('control-center', [
{ id: 'cc-extensions-top', type: 'skill-surface', props: { page: 'control-center', placement: 'after-header' } },
{ id: 'cc-snapshot', type: 'cc-snapshot' },
{ id: 'cc-activity', type: 'cc-activity' },
{ id: 'cc-activity', type: 'cc-activity', data: { series: 'control-center.hiring-activity' } },
{ id: 'cc-pipeline', type: 'cc-pipeline' },
{ id: 'cc-positions', type: 'cc-positions' },
{ id: 'cc-actions', type: 'cc-actions' },