eidotter 0.41.3 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +6 -4
  2. package/dist/components/Bench/components/Bench.d.ts +45 -0
  3. package/dist/components/Bench/components/Bench.js +46 -0
  4. package/dist/components/Bench/components/index.d.ts +2 -0
  5. package/dist/components/Bench/components/index.js +1 -0
  6. package/dist/components/Bench/index.d.ts +2 -0
  7. package/dist/components/Bench/index.js +1 -0
  8. package/dist/components/CmdPalette/components/CmdPalette.js +5 -3
  9. package/dist/components/Header/components/Header.d.ts +38 -2
  10. package/dist/components/Header/components/Header.js +23 -5
  11. package/dist/components/IconButton/components/IconButton.d.ts +20 -3
  12. package/dist/components/IconButton/components/IconButton.js +1 -1
  13. package/dist/components/IconButton/components/index.d.ts +1 -1
  14. package/dist/components/IconButton/index.d.ts +1 -1
  15. package/dist/components/Lightbox/components/Lightbox.d.ts +2 -1
  16. package/dist/components/Lightbox/components/Lightbox.js +8 -3
  17. package/dist/components/Nav/components/Nav.d.ts +20 -7
  18. package/dist/components/Nav/components/index.d.ts +1 -1
  19. package/dist/components/Nav/index.d.ts +1 -1
  20. package/dist/components/PageShell/components/PageSection.d.ts +25 -0
  21. package/dist/components/PageShell/components/PageSection.js +16 -0
  22. package/dist/components/PageShell/components/PageShell.d.ts +58 -0
  23. package/dist/components/PageShell/components/PageShell.js +29 -0
  24. package/dist/components/PageShell/components/index.d.ts +4 -0
  25. package/dist/components/PageShell/components/index.js +2 -0
  26. package/dist/components/PageShell/index.d.ts +2 -0
  27. package/dist/components/PageShell/index.js +1 -0
  28. package/dist/components/RangeBar/components/RangeBar.js +2 -2
  29. package/dist/components/registry.js +39 -0
  30. package/dist/eidotter.css +1 -1
  31. package/dist/index.d.ts +7 -3
  32. package/dist/index.es.js +1047 -1176
  33. package/dist/index.umd.js +4 -4
  34. package/guidelines/README.md +37 -0
  35. package/guidelines/components.md +2 -2
  36. package/llms.txt +4 -4
  37. package/migrations/0.41.0.md +7 -4
  38. package/migrations/0.43.0.md +129 -0
  39. package/migrations/README.md +2 -0
  40. package/package.json +4 -4
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  [![license](https://img.shields.io/badge/license-CC--BY--NC--4.0-ffb000?labelColor=020003)](./LICENSE.md)
16
16
  [![React](https://img.shields.io/badge/React-18%20%7C%2019-ffb000?labelColor=020003)](https://react.dev/)
17
17
 
18
- **eiDotter** is a React component library that takes the DOS/CGA terminal aesthetic seriously: authentic 16-color palette, bitmap-accurate typography, phosphor glow physics, keyboard-first interactions. 59 components across forms, navigation, windowing, timelines, health displays, and terminal chrome — shipped as ES modules with a prebuilt stylesheet so consumers don't need Tailwind to use it.
18
+ **eiDotter** is a React component library that takes the DOS/CGA terminal aesthetic seriously: authentic 16-color palette, bitmap-accurate typography, phosphor glow physics, keyboard-first interactions. 61 components across forms, navigation, windowing, timelines, health displays, and terminal chrome — shipped as ES modules with a prebuilt stylesheet so consumers don't need Tailwind to use it.
19
19
 
20
20
  → **[Storybook](https://cinimody.github.io/eiDotter/)**  ·  **[CHANGELOG](./CHANGELOG.md)**  ·  **[Accessibility](./ACCESSIBILITY.md)**  ·  **[Issues](https://github.com/CinimoDY/eiDotter/issues)**  ·  **[npm](https://www.npmjs.com/package/eidotter)**
21
21
 
@@ -59,6 +59,8 @@ import 'eidotter/utilities'; // Optional: .dos-* typography clas
59
59
 
60
60
  Granular imports are still available (`eidotter/fonts.css`, `eidotter/tokens.css`) if you want to override the font or tokens independently.
61
61
 
62
+ **Fonts in your build output:** your bundler emits the four JetBrains Mono Nerd Font woff2 files (body tier, about 4 MB) even if you never use the body tier. Browsers only download faces a page uses, so this is deploy size, not page weight. To leave them out, see "Fonts: what your build emits" in [`guidelines/README.md`](guidelines/README.md).
63
+
62
64
  **Favicon:** the logomark favicon ships as `eidotter/favicon.svg` (SVG only). Copy it into your site's public dir or point a bundler at it, then `<link rel="icon" href="/favicon.svg" type="image/svg+xml" />`.
63
65
 
64
66
  ### With Tailwind
@@ -114,9 +116,9 @@ for client apps and `'use client'` files.
114
116
 
115
117
  ## Upgrading
116
118
 
117
- Every minor with breaking or visible changes ships an upgrade guide in the package, at `node_modules/eidotter/migrations/<version>.md` (source: [`migrations/`](./migrations/)). Read every guide between your installed version and the target, oldest first. Start with [0.41.0](./migrations/0.41.0.md).
119
+ Every minor with breaking or visible changes ships an upgrade guide in the package, at `node_modules/eidotter/migrations/<version>.md` (source: [`migrations/`](./migrations/)). Read every guide between your installed version and the target, oldest first. Start with [0.41.0](./migrations/0.41.0.md), then [0.43.0](./migrations/0.43.0.md).
118
120
 
119
- 0.x caret ranges stop at the next minor (`^0.40.2` never resolves to 0.41.0), so bump the range explicitly: `npm install eidotter@^0.41.3` (0.41.0 shipped before its review fixes; use the latest 0.41.x, 0.41.3 or later).
121
+ 0.x caret ranges stop at the next minor (`^0.40.2` never resolves to 0.41.0), so bump the range explicitly: `npm install eidotter@^0.43.0`.
120
122
 
121
123
  ## Documentation
122
124
 
@@ -131,7 +133,7 @@ Every minor with breaking or visible changes ships an upgrade guide in the packa
131
133
 
132
134
  ## Components
133
135
 
134
- 59 components across six families — [complete catalog in Storybook](https://cinimody.github.io/eiDotter/). Also exported but not tabled below: CalendarHeatmap, Connector, CopyButton, ChecklistRow, EmptyState, HoldToComplete, LabeledProgress, LedgerRow, ProgressRing, SectionHeading, Select, TimelineFeed, TimelinePage and the `Mark` brand component.
136
+ 61 components across six families — [complete catalog in Storybook](https://cinimody.github.io/eiDotter/). Also exported but not tabled below: Bench, CalendarHeatmap, Connector, CopyButton, ChecklistRow, EmptyState, HoldToComplete, LabeledProgress, LedgerRow, PageShell (with PageSection), ProgressRing, SectionHeading, Select, TimelineFeed, TimelinePage and the `Mark` brand component.
135
137
 
136
138
  <details>
137
139
  <summary><strong>Windowing &amp; shell</strong> — Terminal, DosDialog, DosWindow, IconButton, Modal, Lightbox, CommandPrompt, CmdPalette, Header, Footer, Nav, Brand</summary>
@@ -0,0 +1,45 @@
1
+ import React from 'react';
2
+ export interface BenchPane {
3
+ /** Pane label (`Before`, `After · 390`, …), uppercase in the label row. */
4
+ label: React.ReactNode;
5
+ /** Pane body, usually a screenshot `<img>` (rendered block, full width). */
6
+ content: React.ReactNode;
7
+ /** `after` paints the label in the accent colour. Default `neutral`. */
8
+ tone?: 'before' | 'after' | 'neutral';
9
+ /**
10
+ * `desktop` (default): the scroll frame fills the pane. `phone`: a 390px
11
+ * frame centred with 12px of padding around it.
12
+ */
13
+ frame?: 'desktop' | 'phone';
14
+ /**
15
+ * Accessible name of the pane's scroll frame. Defaults to
16
+ * "<title>, <label>" when both are strings, else the label when it is a
17
+ * string, else "Scrollable pane".
18
+ */
19
+ frameLabel?: string;
20
+ }
21
+ export interface BenchProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
22
+ /** Strip title, rendered as a heading after DosWindow's `▸` marker. */
23
+ title: React.ReactNode;
24
+ /** Heading level of the title. Default `3` (inside a `PageSection` h2). */
25
+ headingLevel?: 2 | 3 | 4 | 5 | 6;
26
+ /** Status chips (`Badge`s), right-aligned in the strip; they wrap. */
27
+ chips?: React.ReactNode;
28
+ /** One muted line under the strip, above the panes. */
29
+ note?: React.ReactNode;
30
+ /** One pane renders full width; two or more sit side by side in pairs. */
31
+ panes: BenchPane[];
32
+ /** Scroll-frame max height, any CSS length. Default `640px` (520px when stacked). */
33
+ maxHeight?: string;
34
+ className?: string;
35
+ }
36
+ /**
37
+ * Bench: a compare unit on DosWindow (0.42). Title strip with chips, an
38
+ * optional note, then panes side by side (before | after) separated by a 1px
39
+ * gap the grid background paints, so no divider elements. Each pane has an
40
+ * uppercase label row and a keyboard-scrollable frame. Below 760px of bench
41
+ * width the panes stack.
42
+ *
43
+ * Server-safe and presentational. Ref forwards to the DosWindow root.
44
+ */
45
+ export declare const Bench: React.ForwardRefExoticComponent<BenchProps & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from 'react';
3
+ import { cn } from '../../../utils/cn.js';
4
+ import { DosWindow } from '../../DosWindow/index.js';
5
+ const toneClasses = {
6
+ before: 'eidotter-bench__pane--before',
7
+ after: 'eidotter-bench__pane--after',
8
+ neutral: 'eidotter-bench__pane--neutral',
9
+ };
10
+ function frameName(pane, title) {
11
+ if (pane.frameLabel)
12
+ return pane.frameLabel;
13
+ if (typeof pane.label === 'string') {
14
+ return typeof title === 'string' ? `${title}, ${pane.label}` : pane.label;
15
+ }
16
+ return 'Scrollable pane';
17
+ }
18
+ /**
19
+ * Bench: a compare unit on DosWindow (0.42). Title strip with chips, an
20
+ * optional note, then panes side by side (before | after) separated by a 1px
21
+ * gap the grid background paints, so no divider elements. Each pane has an
22
+ * uppercase label row and a keyboard-scrollable frame. Below 760px of bench
23
+ * width the panes stack.
24
+ *
25
+ * Server-safe and presentational. Ref forwards to the DosWindow root.
26
+ */
27
+ export const Bench = forwardRef(({ title, headingLevel = 3, chips, note, panes, maxHeight, className, style, ...rest }, ref) => {
28
+ const Heading = `h${headingLevel}`;
29
+ const hasChips = chips != null && chips !== false && chips !== '';
30
+ const hasNote = note != null && note !== false && note !== '';
31
+ const height = maxHeight?.trim();
32
+ const mergedStyle = height
33
+ ? { ...style, '--eidotter-bench-max-height': height }
34
+ : style;
35
+ return (_jsxs(DosWindow, { ref: ref, className: cn('eidotter-bench', className), style: mergedStyle, title: _jsx(Heading, { className: "eidotter-bench__title", children: title }), controls: hasChips ? _jsx("div", { className: "eidotter-bench__chips", children: chips }) : undefined, ...rest, children: [hasNote && _jsx("p", { className: "eidotter-bench__note", children: note }), _jsx("div", { className: cn('eidotter-bench__panes', panes.length === 1 && 'eidotter-bench__panes--single'), children: panes.map((pane, i) => {
36
+ const frame = (_jsx("div", { className: cn('eidotter-bench__frame', pane.frame === 'phone' && 'eidotter-bench__frame--phone'),
37
+ // Scroll containers must be keyboard-reachable (WCAG 2.1.1; the
38
+ // ChatHistory lesson). role=group carries the name without
39
+ // adding a landmark per pane.
40
+ tabIndex: 0, role: "group", "aria-label": frameName(pane, title), children: pane.content }));
41
+ return (_jsxs("div", { className: cn('eidotter-bench__pane', toneClasses[pane.tone ?? 'neutral']), children: [_jsx("div", { className: "eidotter-bench__label", children: pane.label }), pane.frame === 'phone'
42
+ ? _jsx("div", { className: "eidotter-bench__phone", children: frame })
43
+ : frame] }, i));
44
+ }) })] }));
45
+ });
46
+ Bench.displayName = 'Bench';
@@ -0,0 +1,2 @@
1
+ export { Bench } from './Bench.js';
2
+ export type { BenchProps, BenchPane } from './Bench.js';
@@ -0,0 +1 @@
1
+ export { Bench } from './Bench.js';
@@ -0,0 +1,2 @@
1
+ export { Bench } from './components/index.js';
2
+ export type { BenchProps, BenchPane } from './components/index.js';
@@ -0,0 +1 @@
1
+ export { Bench } from './components/index.js';
@@ -1,9 +1,11 @@
1
1
  'use client';
2
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState, } from 'react';
4
4
  import { DosDialog } from '../../DosDialog/components/DosDialog.js';
5
5
  import { cn } from '../../../utils/cn.js';
6
- const defaultFooterHint = (_jsx(_Fragment, { children: "\u2191/\u2193 NAVIGATE \u00B7 \u21B5 OPEN \u00B7 ESC CLOSE" }));
6
+ // Each key hint is one no-wrap unit, so a narrow palette wraps between hints
7
+ // ("ESC CLOSE" never splits across lines, DMNC-1612). Text is unchanged.
8
+ const defaultFooterHint = (_jsxs(_Fragment, { children: [_jsx("span", { className: "eidotter-cmdpal__key-hint", children: "\u2191/\u2193 NAVIGATE" }), ' · ', _jsx("span", { className: "eidotter-cmdpal__key-hint", children: "\u21B5 OPEN" }), ' · ', _jsx("span", { className: "eidotter-cmdpal__key-hint", children: "ESC CLOSE" })] }));
7
9
  /** Split hotkey string like "mod+shift+p" into a predicate. */
8
10
  function parseHotkey(hotkey) {
9
11
  const parts = hotkey.toLowerCase().split('+').map(p => p.trim());
@@ -168,7 +170,7 @@ export const CmdPalette = forwardRef(({ open, onOpenChange, items, placeholder =
168
170
  if (suppressHoverRef.current)
169
171
  return;
170
172
  setSelected(i);
171
- }, onClick: () => handleSelect(item), children: renderItem ? renderItem(item, isSelected) : (_jsxs(_Fragment, { children: [_jsxs("span", { className: cn('eidotter-cmdpal__item-label', isSelected && 'eidotter-cmdpal__text--bright'), children: [_jsx("span", { className: "eidotter-cmdpal__item-caret", "aria-hidden": "true", children: isSelected ? '▸' : ' ' }), item.label] }), item.hint !== undefined && (_jsx("span", { className: cn('eidotter-cmdpal__item-hint', isSelected ? 'eidotter-cmdpal__text--bright' : 'eidotter-cmdpal__text--muted'), children: item.hint }))] })) }, item.id));
173
+ }, onClick: () => handleSelect(item), children: renderItem ? renderItem(item, isSelected) : (_jsxs(_Fragment, { children: [_jsxs("span", { className: cn('eidotter-cmdpal__item-label', isSelected && 'eidotter-cmdpal__text--bright'), children: [_jsx("span", { className: "eidotter-cmdpal__item-caret", "aria-hidden": "true", children: isSelected ? '▸' : ' ' }), _jsx("span", { className: "eidotter-cmdpal__item-text", children: item.label })] }), item.hint !== undefined && (_jsx("span", { className: cn('eidotter-cmdpal__item-hint', isSelected ? 'eidotter-cmdpal__text--bright' : 'eidotter-cmdpal__text--muted'), children: item.hint }))] })) }, item.id));
172
174
  })] })] }));
173
175
  });
174
176
  CmdPalette.displayName = 'CmdPalette';
@@ -56,12 +56,48 @@ export interface HeaderProps extends React.HTMLAttributes<HTMLElement> {
56
56
  * when `children` is provided, or when `brandName` is empty.
57
57
  */
58
58
  brand?: 'text' | 'lockup' | 'mark';
59
+ /**
60
+ * Narrow-header brand presentation for the icon brands (`brand` 'lockup' or
61
+ * 'mark'); ignored for `'text'`, `children` or an empty `brandName`.
62
+ * - `'none'` (default): the name always shows, wrapping onto more lines when
63
+ * it does not fit.
64
+ * - `'mark'`: below 480px of header width (a container query on the header)
65
+ * only the icon shows. The name stays in the DOM, visually hidden, so it
66
+ * remains the brand link's accessible name.
67
+ */
68
+ brandCollapse?: 'none' | 'mark';
69
+ /**
70
+ * Row arrangement:
71
+ * - `'inline'` (default): brand left, nav (+ `end`) right, one row.
72
+ * - `'stacked'`: brand row on top with `end` and the mobile MENU trigger on
73
+ * its right; the desktop nav on its own row below, left-aligned on the
74
+ * brand's edge. Below 768px the nav row is absent (MENU takes over), as
75
+ * in the inline layout.
76
+ */
77
+ layout?: 'inline' | 'stacked';
78
+ /**
79
+ * Bottom rule. `false` removes the 1px bottom border and the retro glow
80
+ * under it (for pages where the header sits in one block with the content).
81
+ * Default `true`.
82
+ */
83
+ rule?: boolean;
84
+ /**
85
+ * Centre the header's content in a max-width column so the brand's left edge
86
+ * lines up with a page column of the same width (e.g. `PageShell`). `true`
87
+ * uses 1240px (PageShell's default); a string is any CSS length
88
+ * (`'72rem'`, `'960px'`). The header itself (background, sticky) still spans
89
+ * the full width, and its inline padding follows the same
90
+ * `max(16px, safe-area inset)` gutter as PageShell. Default off.
91
+ */
92
+ contained?: boolean | string;
59
93
  /**
60
94
  * Trailing slot (theme toggle, ⌘K hint, …). Rendered once: after the desktop
61
95
  * nav on wide screens, and between the brand and the MENU trigger on mobile
62
96
  * (the desktop nav is hidden below 768px, the MENU trigger above it, so DOM
63
- * order gives both placements without duplicating the node). Keep end
64
- * content compact on mobile; it clips before the brand name does.
97
+ * order gives both placements without duplicating the node). With
98
+ * `layout="stacked"` it sits on the brand row at every width. Keep end
99
+ * content compact on mobile: when the row is short of room the brand wraps
100
+ * first, then the end slot clips.
65
101
  * The slot is `overflow: hidden` at every width, so dropdowns, menus and
66
102
  * tooltips opened from `end` must render in a portal (React Aria popovers,
67
103
  * e.g. `Select`, already do); an in-flow popover is clipped.
@@ -9,7 +9,7 @@ const variantClasses = {
9
9
  retro: 'eidotter-header--retro',
10
10
  modern: 'eidotter-header--modern',
11
11
  };
12
- export const Header = forwardRef(({ brandName, brandHref = '/', items, activeHref, variant = 'retro', sticky = true, linkComponent, context, brand = 'lockup', end, skipLinkTarget, children, className, ...rest }, ref) => {
12
+ export const Header = forwardRef(({ brandName, brandHref = '/', items, activeHref, variant = 'retro', sticky = true, linkComponent, context, brand = 'lockup', brandCollapse = 'none', layout = 'inline', rule = true, contained = false, end, skipLinkTarget, children, className, style, ...rest }, ref) => {
13
13
  const LinkTag = linkComponent ?? 'a';
14
14
  // The brand link is structural — an unsafe brandHref falls back to '/' rather
15
15
  // than unmounting the branding anchor.
@@ -20,6 +20,12 @@ export const Header = forwardRef(({ brandName, brandHref = '/', items, activeHre
20
20
  const safeSkipTarget = skipLinkTarget && isSafeHref(skipLinkTarget) ? skipLinkTarget : undefined;
21
21
  // children always win; the icon variants only apply to a non-empty brandName.
22
22
  const iconBrand = !children && !!brandName && brand !== 'text';
23
+ // Mark-only collapse needs an icon to collapse to.
24
+ const collapseToMark = iconBrand && brandCollapse === 'mark';
25
+ const stacked = layout === 'stacked';
26
+ // contained: true → CSS default width; a non-blank string → that CSS length.
27
+ const containedWidth = typeof contained === 'string' ? contained.trim() : '';
28
+ const isContained = contained === true || containedWidth !== '';
23
29
  const brandContent = children || (iconBrand ? (_jsxs(_Fragment, { children: [brand === 'mark'
24
30
  ? _jsx(Mark, { size: 28, label: "", className: "eidotter-header__brand-icon" })
25
31
  : _jsx(Logo, { size: 28, glow: true, title: "", className: "eidotter-header__brand-icon" }), _jsx("span", { className: "eidotter-header__brand-name uppercase tracking-[0.3em]", children: brandName })] })) : brandName);
@@ -33,10 +39,22 @@ export const Header = forwardRef(({ brandName, brandHref = '/', items, activeHre
33
39
  const hasItems = Array.isArray(items) && items.length > 0;
34
40
  const ActionsGroup = hasEnd ? 'div' : React.Fragment;
35
41
  const actionsProps = hasEnd ? { className: 'eidotter-header__actions flex items-center gap-4' } : {};
36
- const main = (_jsxs(_Fragment, { children: [_jsx(LinkTag, { href: safeBrandHref, className: cn('no-underline text-dos-text-base font-bold tracking-wide', 'eidotter-header__branding', iconBrand && 'eidotter-header__branding--icon inline-flex items-center gap-3'), children: brandContent }), _jsxs(ActionsGroup, { ...actionsProps, children: [hasItems && (_jsx(DesktopNav, { items: items, activeHref: activeHref, variant: variant, linkComponent: linkComponent })), hasEnd && _jsx("div", { className: "eidotter-header__end flex items-center", children: end }), hasItems && (_jsx(MobileNav, { items: items, activeHref: activeHref, variant: variant, linkComponent: linkComponent }))] })] }));
42
+ const desktopNav = hasItems ? (_jsx(DesktopNav, { items: items, activeHref: activeHref, variant: variant, linkComponent: linkComponent, className: stacked ? 'eidotter-header__nav-row' : undefined })) : null;
43
+ const main = (_jsxs(_Fragment, { children: [_jsx(LinkTag, { href: safeBrandHref, className: cn('no-underline text-dos-text-base font-bold tracking-wide', 'eidotter-header__branding', iconBrand && 'eidotter-header__branding--icon inline-flex items-start gap-3'), children: brandContent }), _jsxs(ActionsGroup, { ...actionsProps, children: [!stacked && desktopNav, hasEnd && _jsx("div", { className: "eidotter-header__end flex items-center", children: end }), hasItems && (_jsx(MobileNav, { items: items, activeHref: activeHref, variant: variant, linkComponent: linkComponent }))] })] }));
37
44
  const skipLink = safeSkipTarget ? (_jsx("a", { href: safeSkipTarget, className: "eidotter-header__skip", children: "Skip to content" })) : null;
38
- return (_jsxs("header", { ref: ref, className: cn(hasContext
39
- ? 'flex flex-col gap-2 px-4 py-3 font-dos'
40
- : 'flex items-center justify-between px-4 py-3 font-dos', 'eidotter-header', variantClasses[variant], sticky && 'sticky top-0 z-50 bg-dos-bg-primary', className), ...rest, children: [skipLink, hasContext ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "eidotter-header__main flex w-full items-center justify-between", children: main }), _jsx(HeaderContextRow, { context: context, linkComponent: linkComponent, activeHref: activeHref })] })) : (main)] }));
45
+ // Column layout whenever there is more than one row (context row, stacked nav
46
+ // row). The single-row inline header keeps its pre-0.42 DOM: no wrappers.
47
+ const columnLayout = hasContext || stacked;
48
+ const layoutClasses = columnLayout
49
+ ? 'flex flex-col gap-2'
50
+ : 'flex items-center justify-between';
51
+ const rows = columnLayout ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "eidotter-header__main flex w-full items-center justify-between", children: main }), stacked && desktopNav, hasContext && (_jsx(HeaderContextRow, { context: context, linkComponent: linkComponent, activeHref: activeHref }))] })) : (main);
52
+ const mergedStyle = containedWidth
53
+ ? { ...style, '--eidotter-header-max': containedWidth }
54
+ : style;
55
+ return (_jsxs("header", { ref: ref, className: cn(
56
+ // contained: the header is only the full-width band (padding, bg,
57
+ // sticky); the layout moves to the centred inner column.
58
+ isContained ? 'px-4 py-3 font-dos' : cn(layoutClasses, 'px-4 py-3 font-dos'), 'eidotter-header', variantClasses[variant], stacked && 'eidotter-header--stacked', isContained && 'eidotter-header--contained', !rule && 'eidotter-header--no-rule', collapseToMark && 'eidotter-header--collapse-mark', sticky && 'sticky top-0 z-50 bg-dos-bg-primary', className), style: mergedStyle, ...rest, children: [skipLink, isContained ? (_jsx("div", { className: cn('eidotter-header__inner w-full', layoutClasses), children: rows })) : (rows)] }));
41
59
  });
42
60
  Header.displayName = 'Header';
@@ -1,22 +1,39 @@
1
1
  import React from 'react';
2
2
  import type { PressEvent } from 'react-aria-components';
3
3
  import type { IconName } from '../../Icon/components/Icon.js';
4
- /** `md` = 36×36 hit area, 20px glyph (every built-in adopter). `window` = 28×28, 16px glyph, for dense chrome. */
5
- export type IconButtonSize = 'md' | 'window';
4
+ /**
5
+ * `md` = 36×36 hit area, 20px glyph (every built-in adopter). `window` = 28×28,
6
+ * 16px glyph, for dense chrome. `touch` = 44×44, 24px glyph, a full-size touch
7
+ * target for stage and kiosk shells.
8
+ */
9
+ export type IconButtonSize = 'md' | 'window' | 'touch';
6
10
  /** `outline` = 1px square outline at rest (default). `bare` = no resting outline. */
7
11
  export type IconButtonVariant = 'outline' | 'bare';
12
+ /**
13
+ * `accent` = the house `text-accent` glyph with the amber hover invert
14
+ * (default). `inherit` = glyph, outline and focus ring take the surrounding
15
+ * `currentColor`, for coloured bars; hover and press tint the button with that
16
+ * colour instead of the amber fill.
17
+ */
18
+ export type IconButtonTone = 'accent' | 'inherit';
8
19
  export interface IconButtonProps {
9
20
  /** Glyph from the `<Icon>` set: `Close` (X), `Cancel` (minus, minimise), `Fullscreen` (maximise), `Chevron Left/Right/Up/Down`. */
10
21
  icon: IconName;
11
22
  /** Accessible name. Required: the button has no visible text. Rendered as `aria-label`. */
12
23
  label: string;
13
- /** Hit-area size. Defaults to `md` (36×36). */
24
+ /** Hit-area size. Defaults to `md` (36×36); `touch` is 44×44. */
14
25
  size?: IconButtonSize;
15
26
  /**
16
27
  * `outline` (default) shows the thin square outline at rest. `bare` drops
17
28
  * it, for places where an outline would fight a neighbouring rule.
18
29
  */
19
30
  variant?: IconButtonVariant;
31
+ /**
32
+ * Colour source. `accent` (default) is the house look. `inherit` uses the
33
+ * parent's text colour (`currentColor`) for the glyph, outline and focus
34
+ * ring, so the button reads on a coloured status bar; set the bar's `color`.
35
+ */
36
+ tone?: IconButtonTone;
20
37
  /** Disables the button (opacity 0.5, no hover invert). */
21
38
  isDisabled?: boolean;
22
39
  /** Called on press (mouse, touch, Enter, Space). */
@@ -15,5 +15,5 @@ import { cn } from '../../../utils/cn.js';
15
15
  * React Aria `Button` handles press, keyboard and focus-visible. Ref forwards
16
16
  * to the `<button>`.
17
17
  */
18
- export const IconButton = forwardRef(({ icon, label, size = 'md', variant = 'outline', isDisabled, onPress, excludeFromTabOrder, className, }, ref) => (_jsx(AriaButton, { ref: ref, "aria-label": label, isDisabled: isDisabled, onPress: onPress, excludeFromTabOrder: excludeFromTabOrder, className: cn('inline-flex items-center justify-center shrink-0', 'eidotter-icon-button', size === 'window' && 'eidotter-icon-button--window', variant === 'bare' && 'eidotter-icon-button--bare', className), children: _jsx("span", { className: "eidotter-icon-button__glyph", "aria-hidden": "true", children: _jsx(Icon, { name: icon, size: "S", decorative: true }) }) })));
18
+ export const IconButton = forwardRef(({ icon, label, size = 'md', variant = 'outline', tone = 'accent', isDisabled, onPress, excludeFromTabOrder, className, }, ref) => (_jsx(AriaButton, { ref: ref, "aria-label": label, isDisabled: isDisabled, onPress: onPress, excludeFromTabOrder: excludeFromTabOrder, className: cn('inline-flex items-center justify-center shrink-0', 'eidotter-icon-button', size === 'window' && 'eidotter-icon-button--window', size === 'touch' && 'eidotter-icon-button--touch', variant === 'bare' && 'eidotter-icon-button--bare', tone === 'inherit' && 'eidotter-icon-button--inherit', className), children: _jsx("span", { className: "eidotter-icon-button__glyph", "aria-hidden": "true", children: _jsx(Icon, { name: icon, size: "S", decorative: true }) }) })));
19
19
  IconButton.displayName = 'IconButton';
@@ -1,2 +1,2 @@
1
1
  export { IconButton } from './IconButton.js';
2
- export type { IconButtonProps, IconButtonSize, IconButtonVariant } from './IconButton.js';
2
+ export type { IconButtonProps, IconButtonSize, IconButtonVariant, IconButtonTone } from './IconButton.js';
@@ -1,2 +1,2 @@
1
1
  export { IconButton } from './components/index.js';
2
- export type { IconButtonProps, IconButtonSize, IconButtonVariant } from './components/index.js';
2
+ export type { IconButtonProps, IconButtonSize, IconButtonVariant, IconButtonTone } from './components/index.js';
@@ -18,7 +18,8 @@ export interface LightboxProps {
18
18
  * The shell supplies the overlay, chrome, motion and the title strip;
19
19
  * the `[ n / N ]` counter is the strip's title and the close control sits at
20
20
  * its end (an IconButton). Lightbox adds prev/next (IconButtons with the pixel
21
- * chevrons), caption, keyboard nav, and touch swipe.
21
+ * chevrons; omitted when there is only one image), caption, keyboard nav, and
22
+ * touch swipe. A figure taller than the body scrolls from its top edge.
22
23
  * It shares the shell's z-index (`--z-index-modal`) with Modal; stacking a
23
24
  * Lightbox over an open Modal is not a supported case.
24
25
  *
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import React, { useCallback, useEffect, useState } from 'react';
4
4
  import { IconButton } from '../../IconButton/components/IconButton.js';
5
5
  import { DosDialog } from '../../DosDialog/components/DosDialog.js';
@@ -9,7 +9,8 @@ import { DosDialog } from '../../DosDialog/components/DosDialog.js';
9
9
  * The shell supplies the overlay, chrome, motion and the title strip;
10
10
  * the `[ n / N ]` counter is the strip's title and the close control sits at
11
11
  * its end (an IconButton). Lightbox adds prev/next (IconButtons with the pixel
12
- * chevrons), caption, keyboard nav, and touch swipe.
12
+ * chevrons; omitted when there is only one image), caption, keyboard nav, and
13
+ * touch swipe. A figure taller than the body scrolls from its top edge.
13
14
  * It shares the shell's z-index (`--z-index-modal`) with Modal; stacking a
14
15
  * Lightbox over an open Modal is not a supported case.
15
16
  *
@@ -96,10 +97,14 @@ export const Lightbox = ({ images, initialIndex = 0, isOpen, onClose, onIndexCha
96
97
  const current = images[safeIndex];
97
98
  const hasPrev = safeIndex > 0;
98
99
  const hasNext = safeIndex < images.length - 1;
100
+ // One image: nothing to navigate, so no prev/next at all (two permanently
101
+ // disabled buttons are noise). The `[ 1 / 1 ]` counter stays as the strip
102
+ // title, so the strip keeps its heading and the position stays explicit.
103
+ const canNavigate = images.length > 1;
99
104
  return (_jsx(DosDialog, { open: isOpen, onOpenChange: (open) => { if (!open)
100
105
  onClose(); }, placement: "center", size: "full", "aria-label": current.alt || 'Image viewer', className: "eidotter-lightbox__container", head: {
101
106
  title: (_jsxs("span", { className: "eidotter-lightbox__counter", "aria-live": "polite", children: ["[ ", safeIndex + 1, " / ", images.length, " ]"] })),
102
107
  end: (_jsx(IconButton, { icon: "Close", label: "Close lightbox", className: "eidotter-lightbox__close", onPress: onClose })),
103
- }, children: _jsxs("figure", { className: "eidotter-lightbox__figure", children: [_jsx("img", { className: "eidotter-lightbox__image", src: current.src, alt: current.alt, width: current.width, height: current.height, onTouchStart: onTouchStart, onTouchEnd: onTouchEnd }), _jsx(IconButton, { icon: "Chevron Left", label: "Previous image", className: "eidotter-lightbox__nav eidotter-lightbox__nav--prev", onPress: goPrev, isDisabled: !hasPrev }), _jsx(IconButton, { icon: "Chevron Right", label: "Next image", className: "eidotter-lightbox__nav eidotter-lightbox__nav--next", onPress: goNext, isDisabled: !hasNext }), current.caption && (_jsx("figcaption", { className: "eidotter-lightbox__caption", children: current.caption }))] }) }));
108
+ }, children: _jsxs("figure", { className: "eidotter-lightbox__figure", children: [_jsx("img", { className: "eidotter-lightbox__image", src: current.src, alt: current.alt, width: current.width, height: current.height, onTouchStart: onTouchStart, onTouchEnd: onTouchEnd }), canNavigate && (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: "Chevron Left", label: "Previous image", className: "eidotter-lightbox__nav eidotter-lightbox__nav--prev", onPress: goPrev, isDisabled: !hasPrev }), _jsx(IconButton, { icon: "Chevron Right", label: "Next image", className: "eidotter-lightbox__nav eidotter-lightbox__nav--next", onPress: goNext, isDisabled: !hasNext })] })), current.caption && (_jsx("figcaption", { className: "eidotter-lightbox__caption", children: current.caption }))] }) }));
104
109
  };
105
110
  Lightbox.displayName = 'Lightbox';
@@ -5,6 +5,24 @@ export interface NavItem {
5
5
  /** URL/path for the link */
6
6
  href: string;
7
7
  }
8
+ /** Props eidotter passes to a custom link component. */
9
+ export interface NavLinkProps {
10
+ href: string;
11
+ className?: string;
12
+ children: React.ReactNode;
13
+ onClick?: () => void;
14
+ 'aria-current'?: 'page';
15
+ }
16
+ /**
17
+ * A router link component for `linkComponent`: any component that accepts
18
+ * {@link NavLinkProps}. Its own props may be wider (Next.js `Link` takes
19
+ * `href: string | UrlObject`). Function and class components match
20
+ * `ComponentType`; `forwardRef`/`memo` components such as `next/link` match
21
+ * `ExoticComponent`, which has no `propTypes` member, so the `propTypes`
22
+ * comparison that rejected `next/link` under @types/react 18 does not apply
23
+ * (DMNC-1612). Works with @types/react 18 and 19, no wrapper needed.
24
+ */
25
+ export type NavLinkComponent = React.ComponentType<NavLinkProps> | React.ExoticComponent<NavLinkProps>;
8
26
  export interface NavProps {
9
27
  /** Navigation items */
10
28
  items: NavItem[];
@@ -13,15 +31,10 @@ export interface NavProps {
13
31
  /** Visual variant */
14
32
  variant?: 'retro' | 'modern';
15
33
  /**
16
- * Custom link component (e.g., Next.js Link).
34
+ * Custom link component (e.g. Next.js `Link`, passed directly).
17
35
  * If not provided, uses regular anchor tags.
18
36
  */
19
- linkComponent?: React.ComponentType<{
20
- href: string;
21
- className?: string;
22
- children: React.ReactNode;
23
- onClick?: () => void;
24
- }>;
37
+ linkComponent?: NavLinkComponent;
25
38
  /** Additional CSS class name */
26
39
  className?: string;
27
40
  }
@@ -1,2 +1,2 @@
1
1
  export { Nav, DesktopNav, MobileNav } from './Nav.js';
2
- export type { NavProps, NavItem } from './Nav.js';
2
+ export type { NavProps, NavItem, NavLinkProps, NavLinkComponent } from './Nav.js';
@@ -1,2 +1,2 @@
1
1
  export { Nav, DesktopNav, MobileNav } from './components/index.js';
2
- export type { NavProps, NavItem } from './components/index.js';
2
+ export type { NavProps, NavItem, NavLinkProps, NavLinkComponent } from './components/index.js';
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+ export interface PageSectionProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
3
+ /**
4
+ * Section heading, in the display face and accent colour, with no rule under
5
+ * it. Omit for an untitled section.
6
+ */
7
+ title?: React.ReactNode;
8
+ /** Heading level. Default `2` (sections directly under the page `<h1>`). */
9
+ headingLevel?: 2 | 3 | 4;
10
+ /**
11
+ * Anchor id (TOC target). When set with a `title`, the heading gets
12
+ * `${id}-heading` and the section is labelled by it, so it becomes a named
13
+ * region.
14
+ */
15
+ id?: string;
16
+ children?: React.ReactNode;
17
+ className?: string;
18
+ }
19
+ /**
20
+ * PageSection: one block of a `PageShell` page (0.43). A `<section>` whose
21
+ * heading and children stack with a 16px gap. No divider above or below.
22
+ *
23
+ * Server-safe. Ref forwards to the `<section>`.
24
+ */
25
+ export declare const PageSection: React.ForwardRefExoticComponent<PageSectionProps & React.RefAttributes<HTMLElement>>;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from 'react';
3
+ import { cn } from '../../../utils/cn.js';
4
+ /**
5
+ * PageSection: one block of a `PageShell` page (0.43). A `<section>` whose
6
+ * heading and children stack with a 16px gap. No divider above or below.
7
+ *
8
+ * Server-safe. Ref forwards to the `<section>`.
9
+ */
10
+ export const PageSection = forwardRef(({ title, headingLevel = 2, id, children, className, ...rest }, ref) => {
11
+ const hasTitle = title != null && title !== false && title !== '';
12
+ const Heading = `h${headingLevel}`;
13
+ const headingId = hasTitle && id ? `${id}-heading` : undefined;
14
+ return (_jsxs("section", { ref: ref, id: id, "aria-labelledby": headingId, className: cn('eidotter-page-section', className), ...rest, children: [hasTitle && (_jsx(Heading, { id: headingId, className: "eidotter-page-section__title", children: title })), children] }));
15
+ });
16
+ PageSection.displayName = 'PageSection';
@@ -0,0 +1,58 @@
1
+ import React from 'react';
2
+ export interface PageShellTocItem {
3
+ /** In-page anchor (`#findings`) or any safe URL. Unsafe schemes render no anchor. */
4
+ href: string;
5
+ /** Chip text. */
6
+ label: React.ReactNode;
7
+ }
8
+ export interface PageShellIntro {
9
+ /** Small uppercase line above the title (context, version, date). */
10
+ eyebrow?: React.ReactNode;
11
+ /** The page title, rendered as the page's `<h1>` in the display face. */
12
+ title: React.ReactNode;
13
+ /** Lead paragraph under the title (max 68ch). */
14
+ lead?: React.ReactNode;
15
+ /** A wrapping row of facts (author, branch, captured at, …). */
16
+ meta?: React.ReactNode;
17
+ /** Table-of-contents chips. Rendered as a labelled `<nav>` of links. */
18
+ toc?: PageShellTocItem[];
19
+ /** Accessible name of the TOC `<nav>`. Default `"On this page"`. */
20
+ tocLabel?: string;
21
+ }
22
+ export interface PageShellProps extends React.HTMLAttributes<HTMLDivElement> {
23
+ /**
24
+ * Top slot, inside the column so it shares the content's left edge.
25
+ * Typically `<Header layout="stacked" rule={false} …/>`; a Header placed
26
+ * here loses its own inline padding so its brand sits on the column edge.
27
+ */
28
+ header?: React.ReactNode;
29
+ /** Structured page intro: eyebrow, `<h1>` title, lead, meta row, TOC chips. */
30
+ intro?: PageShellIntro;
31
+ /** Bottom slot, inside the column (e.g. `<Footer>`). */
32
+ footer?: React.ReactNode;
33
+ /** Column max-width, any CSS length. Default `1240px`. */
34
+ maxWidth?: string;
35
+ /**
36
+ * Element that wraps `intro` and `children`. `'main'` (default) makes it the
37
+ * page's main landmark; use `'div'` when the app layout already renders a
38
+ * `<main>`.
39
+ */
40
+ contentAs?: 'main' | 'div';
41
+ /** `id` for the content element, e.g. a Header `skipLinkTarget` (`#content`). */
42
+ contentId?: string;
43
+ /** Page sections (`PageSection`, Bench, …), stacked with a 40px gap. */
44
+ children?: React.ReactNode;
45
+ className?: string;
46
+ }
47
+ /**
48
+ * PageShell: the "one block" page layout (0.43).
49
+ *
50
+ * One centred column (1240px by default) with a `max(16px, safe-area)` side
51
+ * gutter and 24px / 64px block padding. Header, intro, sections and footer
52
+ * all live inside the column, so everything shares one left edge, stacked
53
+ * with a 40px gap. No dividers: the only lines on the page are the ones its
54
+ * children draw (window frames, pane gaps).
55
+ *
56
+ * Server-safe and presentational. Ref forwards to the root `<div>`.
57
+ */
58
+ export declare const PageShell: React.ForwardRefExoticComponent<PageShellProps & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from 'react';
3
+ import { cn } from '../../../utils/cn.js';
4
+ import { isSafeHref } from '../../../utils/isSafeHref.js';
5
+ function PageIntro({ intro }) {
6
+ const { eyebrow, title, lead, meta, toc, tocLabel = 'On this page' } = intro;
7
+ const has = (node) => node != null && node !== false && node !== '';
8
+ return (_jsxs("header", { className: "eidotter-page-shell__intro", children: [has(eyebrow) && _jsx("div", { className: "eidotter-page-shell__eyebrow", children: eyebrow }), _jsx("h1", { className: "eidotter-page-shell__title", children: title }), has(lead) && _jsx("p", { className: "eidotter-page-shell__lead", children: lead }), has(meta) && _jsx("div", { className: "eidotter-page-shell__meta", children: meta }), toc && toc.length > 0 && (_jsx("nav", { className: "eidotter-page-shell__toc", "aria-label": tocLabel, children: _jsx("ul", { className: "eidotter-page-shell__toc-list", children: toc.map((item, i) => (_jsx("li", { children: isSafeHref(item.href) ? (_jsx("a", { className: "eidotter-page-shell__toc-link", href: item.href, children: item.label })) : (_jsx("span", { className: "eidotter-page-shell__toc-link", children: item.label })) }, `${item.href}-${i}`))) }) }))] }));
9
+ }
10
+ /**
11
+ * PageShell: the "one block" page layout (0.43).
12
+ *
13
+ * One centred column (1240px by default) with a `max(16px, safe-area)` side
14
+ * gutter and 24px / 64px block padding. Header, intro, sections and footer
15
+ * all live inside the column, so everything shares one left edge, stacked
16
+ * with a 40px gap. No dividers: the only lines on the page are the ones its
17
+ * children draw (window frames, pane gaps).
18
+ *
19
+ * Server-safe and presentational. Ref forwards to the root `<div>`.
20
+ */
21
+ export const PageShell = forwardRef(({ header, intro, footer, maxWidth, contentAs = 'main', contentId, children, className, style, ...rest }, ref) => {
22
+ const Content = contentAs;
23
+ const width = maxWidth?.trim();
24
+ const mergedStyle = width
25
+ ? { ...style, '--eidotter-page-shell-max': width }
26
+ : style;
27
+ return (_jsx("div", { ref: ref, className: cn('eidotter-page-shell', className), style: mergedStyle, ...rest, children: _jsxs("div", { className: "eidotter-page-shell__column", children: [header != null && header !== false && (_jsx("div", { className: "eidotter-page-shell__header", children: header })), _jsxs(Content, { id: contentId, className: "eidotter-page-shell__content", children: [intro && _jsx(PageIntro, { intro: intro }), children] }), footer != null && footer !== false && (_jsx("div", { className: "eidotter-page-shell__footer", children: footer }))] }) }));
28
+ });
29
+ PageShell.displayName = 'PageShell';
@@ -0,0 +1,4 @@
1
+ export { PageShell } from './PageShell.js';
2
+ export type { PageShellProps, PageShellIntro, PageShellTocItem } from './PageShell.js';
3
+ export { PageSection } from './PageSection.js';
4
+ export type { PageSectionProps } from './PageSection.js';
@@ -0,0 +1,2 @@
1
+ export { PageShell } from './PageShell.js';
2
+ export { PageSection } from './PageSection.js';
@@ -0,0 +1,2 @@
1
+ export { PageShell, PageSection } from './components/index.js';
2
+ export type { PageShellProps, PageShellIntro, PageShellTocItem, PageSectionProps } from './components/index.js';
@@ -0,0 +1 @@
1
+ export { PageShell, PageSection } from './components/index.js';