Skip to the content

Capsid menu

components/authorship/authorship.react.tsx

128 lines and 13 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.

Functions

The functions of authorship.react.tsx
FunctionLinesComplexityCoveredCRAPMutation
Run17 to 352No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
collect38 to 471No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:39>39 to 467No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
Meter49 to 801No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:52>52 to 574No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:54>54 to 541No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:65>65 to 671No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:71>71 to 711No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:72>72 to 762No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
Authorship95 to 1354No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:100>100 to 1085No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:111>111 to 1111No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:118>118 to 1284No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet

Imports

It imports components/authorship/authorship.ts, components/switch/switch.react.tsx. Imported by components/authorship/examples.tsx.

Source

import { Children, isValidElement, useEffect, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
import { Switch } from "../switch/switch.react.tsx";
import { KINDS, KIND_LABEL, STORAGE_KEY, summarise, type Kind, type RunInput, type Summary } from "./authorship.ts";

export type { Kind };

export interface RunProps {
  kind: Kind;
  // A block (a div around paragraphs) rather than a span inside one.
  block?: boolean;
  // Something small before the word on the label: an Avatar with aria-hidden, for example.
  badge?: ReactNode;
  children?: ReactNode;
}

// One passage and who wrote it: the rule in the margin and the label ("You", "AI", "Quoted").
export function Run({ kind, block = false, badge, children }: RunProps) {
  const label = (
    <span className="cap-run-label">
      {badge}
      {KIND_LABEL[kind]}
    </span>
  );
  return block ? (
    <div className="cap-run" data-kind={kind}>
      {label}
      {children}
    </div>
  ) : (
    <span className="cap-run" data-kind={kind}>
      {label}
      {children}
    </span>
  );
}

// The words under each Run, found by walking the children (text nested in elements counts).
function collect(node: ReactNode, kind: Kind | null, out: RunInput[]): void {
  Children.forEach(node, (child) => {
    if (typeof child === "string" || typeof child === "number") {
      if (kind) out.push({ kind, text: String(child) });
    } else if (isValidElement(child)) {
      const el = child as ReactElement<{ kind?: Kind; children?: ReactNode }>;
      collect(el.props.children, el.type === Run && el.props.kind ? el.props.kind : kind, out);
    }
  });
}

function Meter({ summary }: { summary: Summary }) {
  const bar = useRef<HTMLSpanElement>(null);
  // Widths go through the CSSOM, never a style prop (style-src 'self').
  useLayoutEffect(() => {
    for (const seg of bar.current?.querySelectorAll<HTMLElement>(".cap-authorship-seg") ?? []) {
      const share = summary.shares.find((s) => s.kind === seg.dataset.kind);
      seg.style.setProperty("--cap-share", String(share?.percent ?? 0));
    }
  }, [summary]);
  return (
    <div className="cap-meter cap-authorship-summary" data-cap-part="summary">
      <span className="cap-meter-label">Authorship</span>
      <span className="cap-meter-value" data-cap-part="text">
        {summary.text}
      </span>
      <span className="cap-meter-bar" aria-hidden="true" ref={bar}>
        {summary.shares.map((s) => (
          <span key={s.kind} className="cap-authorship-seg" data-kind={s.kind} data-share={s.percent} />
        ))}
      </span>
      <span className="cap-meter-note" data-cap-part="notes">
        {summary.shares
          .filter((s) => s.words > 0)
          .map((s) => (
            <span key={s.kind}>
              {KIND_LABEL[s.kind]}: {s.words.toLocaleString("en")} {s.words === 1 ? "word" : "words"}
            </span>
          ))}
      </span>
    </div>
  );
}

export interface AuthorshipProps {
  // Controlled; leave both out and the component remembers the person's choice.
  shown?: boolean;
  onShownChange?: (shown: boolean) => void;
  // Shown unless the person chose otherwise. Default true.
  defaultShown?: boolean;
  // Keep the choice in localStorage (the uncontrolled default).
  remember?: boolean;
  // The runs, as <Run>s inside your paragraphs.
  children: ReactNode;
}

// The switch, the summary and the marked text. The summary counts the words under each Run.
export function Authorship({ shown, onShownChange, defaultShown = true, remember = true, children }: AuthorshipProps) {
  const [own, setOwn] = useState(defaultShown);
  const on = shown ?? own;
  const keep = remember && shown === undefined;
  // The remembered choice is read after mount, so a server render and the first client render agree.
  useEffect(() => {
    if (!keep) return;
    try {
      const v = localStorage.getItem(STORAGE_KEY);
      if (v === "on" || v === "off") setOwn(v === "on");
    } catch {
      // Storage blocked: the default stands.
    }
  }, [keep]);
  const runs: RunInput[] = [];
  collect(children, null, runs);
  const summary = summarise(runs.filter((r) => KINDS.includes(r.kind)));
  return (
    <div className="cap-authorship" data-authorship={on ? "on" : "off"}>
      <div className="cap-authorship-bar">
        <Switch
          label="Show authorship"
          checked={on}
          onCheckedChange={(next) => {
            setOwn(next);
            onShownChange?.(next);
            if (keep) {
              try {
                localStorage.setItem(STORAGE_KEY, next ? "on" : "off");
              } catch {
                // Not remembered; the choice still applies now.
              }
            }
          }}
        />
        <Meter summary={summary} />
      </div>
      <div className="cap-authorship-text">{children}</div>
    </div>
  );
}