@estiva-app/ui 0.38.0 → 0.40.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.38.0",
8
- "exports": 88,
9
- "typeExports": 97,
7
+ "packageVersion": "0.40.0",
8
+ "exports": 90,
9
+ "typeExports": 101,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -21,12 +21,12 @@
21
21
  "kind": "component",
22
22
  "importPath": "@estiva-app/ui",
23
23
  "sourceFile": "src/AppShell.tsx",
24
- "purpose": "The frame of an app, in the two manners that pair: `solid` — the structured frame, a solid TopBar over a Sidebar and the content beside it — and `floating` — the TopBar floating over the top edge, a Rail standing on the background, and the content living in the rounded card.",
24
+ "purpose": "The frame of an app, in three manners: `solid` — the structured frame, a solid TopBar over a Sidebar and the content beside it — `inset` — the same frame the other way round, the TopBar and the Sidebar standing on the ground and the content a lighter card beside them — and `floating` — the TopBar floating over the top edge, a Rail standing on the background, and the content living in the rounded card.",
25
25
  "purposeFrom": "page",
26
26
  "props": [
27
27
  {
28
28
  "name": "variant",
29
- "takes": "solid | floating",
29
+ "takes": "solid | floating | inset",
30
30
  "required": false,
31
31
  "note": null
32
32
  },
@@ -78,7 +78,8 @@
78
78
  "prop": "variant",
79
79
  "values": [
80
80
  "solid",
81
- "floating"
81
+ "floating",
82
+ "inset"
82
83
  ]
83
84
  }
84
85
  ],
@@ -4078,6 +4079,93 @@
4078
4079
  "app": null,
4079
4080
  "looks": []
4080
4081
  },
4082
+ {
4083
+ "name": "RichText",
4084
+ "repo": "estiva-ui",
4085
+ "kind": "component",
4086
+ "importPath": "@estiva-app/ui",
4087
+ "sourceFile": "src/RichText.tsx",
4088
+ "purpose": "Formatted text, drawn from a parsed tree: headings, paragraphs, both lists, a quote, code, a divider, a table and links, in one look for every app.",
4089
+ "purposeFrom": "page",
4090
+ "props": [
4091
+ {
4092
+ "name": "blocks",
4093
+ "takes": "readonly RichTextBlock[]",
4094
+ "required": true,
4095
+ "note": "The tree, already parsed."
4096
+ },
4097
+ {
4098
+ "name": "size",
4099
+ "takes": "default | small",
4100
+ "required": false,
4101
+ "note": "Default `default`, the size of text you read."
4102
+ },
4103
+ {
4104
+ "name": "renderText",
4105
+ "takes": "a handler",
4106
+ "required": false,
4107
+ "note": "Draws plain text: what is left of a run once its links are drawn."
4108
+ },
4109
+ {
4110
+ "name": "renderReference",
4111
+ "takes": "a handler",
4112
+ "required": false,
4113
+ "note": "Draws a run that stands for an address. By default its text."
4114
+ },
4115
+ {
4116
+ "name": "renderBlock",
4117
+ "takes": "a handler",
4118
+ "required": false,
4119
+ "note": "Draws a block this part has no design for (an attachment, a widget)."
4120
+ }
4121
+ ],
4122
+ "variants": [
4123
+ {
4124
+ "prop": "size",
4125
+ "values": [
4126
+ "default",
4127
+ "small"
4128
+ ]
4129
+ }
4130
+ ],
4131
+ "ownsBehaviours": [],
4132
+ "status": "stable",
4133
+ "migrationStage": null,
4134
+ "docsId": "components-richtext--docs",
4135
+ "storyId": "components-richtext--every-block",
4136
+ "docPage": "src/RichText.mdx",
4137
+ "app": null,
4138
+ "looks": [
4139
+ "[&>:is(h1,h2,h3)]:mb-1 [&>:is(h1,h2,h3)]:mt-2 [&>:is(h1,h2,h3):first-child]:mt-0 [&>:is(h1,h2,h3):last-child]:mb-0 [&>:is(h1,h2,h3)+:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2)]:text-text-primary",
4140
+ "[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-muted [&_ol]:list-decimal [&_ul]:list-disc",
4141
+ "[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-border-strong [&_blockquote]:pl-2.5",
4142
+ "[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_code]:text-text-primary",
4143
+ "[&_pre>code]:text-[1em] [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:rounded-md [&_pre]:bg-bg-code [&_pre]:p-2 [&_pre]:font-mono [&_pre]:text-[0.9em] [&_pre]:text-text-primary",
4144
+ "[&_:is(th,td)>p]:m-0 [&_:is(th,td)]:border [&_:is(th,td)]:border-border-default [&_:is(th,td)]:px-2 [&_:is(th,td)]:py-1 [&_:is(th,td)]:text-left [&_:is(th,td)]:align-top [&_table]:w-full [&_table]:border-collapse [&_th]:bg-bg-code [&_th]:font-medium [&_th]:text-text-primary",
4145
+ "[&_em]:italic [&_strong]:font-semibold [&_u]:underline [&_u]:underline-offset-2",
4146
+ "text-body-2 [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4",
4147
+ "text-caption leading-snug [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold"
4148
+ ]
4149
+ },
4150
+ {
4151
+ "name": "richTextClassName",
4152
+ "repo": "estiva-ui",
4153
+ "kind": "helper",
4154
+ "importPath": "@estiva-app/ui",
4155
+ "sourceFile": "src/RichText.tsx",
4156
+ "purpose": "RichText's look, for a box you draw yourself: an editor's, so that what is being written looks the same as what is read.",
4157
+ "purposeFrom": "comment",
4158
+ "props": [],
4159
+ "variants": [],
4160
+ "ownsBehaviours": [],
4161
+ "status": "stable",
4162
+ "migrationStage": null,
4163
+ "docsId": "components-richtext--docs",
4164
+ "storyId": "components-richtext--every-block",
4165
+ "docPage": null,
4166
+ "app": null,
4167
+ "looks": []
4168
+ },
4081
4169
  {
4082
4170
  "name": "ScrollArea",
4083
4171
  "repo": "estiva-ui",
@@ -5412,7 +5500,7 @@
5412
5500
  "props": [
5413
5501
  {
5414
5502
  "name": "variant",
5415
- "takes": "solid | floating",
5503
+ "takes": "solid | floating | inset",
5416
5504
  "required": false,
5417
5505
  "note": null
5418
5506
  },
@@ -5452,7 +5540,8 @@
5452
5540
  "prop": "variant",
5453
5541
  "values": [
5454
5542
  "solid",
5455
- "floating"
5543
+ "floating",
5544
+ "inset"
5456
5545
  ]
5457
5546
  }
5458
5547
  ],
package/src/AppShell.mdx CHANGED
@@ -5,14 +5,18 @@ import * as AppShellStories from './AppShell.stories'
5
5
 
6
6
  # AppShell
7
7
 
8
- The frame of an app, in the two manners that pair: **`solid`** — the
9
- structured frame, a solid TopBar over a Sidebar and the content beside
10
- it — and **`floating`** — the TopBar floating over the top edge, a Rail
8
+ The frame of an app, in three manners: **`solid`** — the structured
9
+ frame, a solid TopBar over a Sidebar and the content beside it —
10
+ **`inset`** — the same frame the other way round, the TopBar and the
11
+ Sidebar standing on the ground and the content a lighter card beside
12
+ them — and **`floating`** — the TopBar floating over the top edge, a Rail
11
13
  standing on the background, and the content living in the rounded card.
12
14
  Layout only: no data, no routes.
13
15
 
14
16
  <Canvas of={AppShellStories.Solid} />
15
17
 
18
+ <Canvas of={AppShellStories.Inset} />
19
+
16
20
  <Canvas of={AppShellStories.Floating} />
17
21
 
18
22
  ## When
@@ -20,6 +24,10 @@ Layout only: no data, no routes.
20
24
  - **`solid` + Sidebar** — a place-by-place app: labelled entries, counts,
21
25
  groups. Sidebar and main scroll independently, so a long list never
22
26
  scrolls the navigation away.
27
+ - **`inset` + Sidebar** — the same place-by-place app, when its pages
28
+ read as one sheet on a desk: the content is a card 16px from the right
29
+ and bottom, with 8px corners, and the Sidebar drops its line and surface
30
+ by itself. Any theme: the ground and the card are its base and surface.
23
31
  - **`floating` + Rail** — an app with a handful of top-level places and
24
32
  its content in one card; the `menu` button in the bar collapses the
25
33
  rail (the collapsing stays the app's).
@@ -95,6 +95,25 @@ export const SolidWithBanner: Story = {
95
95
  ),
96
96
  }
97
97
 
98
+ /** The structured frame the other way round: the bar and the sidebar on the ground, the content a lighter card beside them. */
99
+ export const Inset: Story = {
100
+ args: { variant: 'inset' },
101
+ render: (args) => (
102
+ <AppShell {...args} logo="Estiva" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} identity={identity} nav={sidebar}>
103
+ <EmptyState message="Nothing here yet." />
104
+ </AppShell>
105
+ ),
106
+ }
107
+
108
+ export const InsetWithBanner: Story = {
109
+ args: { variant: 'inset' },
110
+ render: (args) => (
111
+ <AppShell {...args} logo="Estiva" identity={identity} nav={sidebar} banner={<Banner tone="ok">Public key copied.</Banner>}>
112
+ <EmptyState message="Nothing here yet." />
113
+ </AppShell>
114
+ ),
115
+ }
116
+
98
117
  /** The floating frame: the bar floats over the top edge, the rail stands on the background, and the content lives in the rounded card. */
99
118
  export const Floating: Story = {
100
119
  render: (args) => (
@@ -9,6 +9,7 @@
9
9
  import { afterEach, describe, expect, it } from 'vitest'
10
10
  import { cleanup, render, screen } from '@testing-library/react'
11
11
  import { AppShell } from './AppShell'
12
+ import { Sidebar } from './Sidebar'
12
13
 
13
14
  afterEach(cleanup)
14
15
 
@@ -26,6 +27,25 @@ describe('AppShell', () => {
26
27
  expect(main.className).not.toContain('overflow-y-auto')
27
28
  })
28
29
 
30
+ it('stands the sidebar on the ground and makes the content a panel in the inset manner, and only there', () => {
31
+ const frame = (variant: 'solid' | 'inset') =>
32
+ render(
33
+ <AppShell variant={variant} nav={<Sidebar>Rows</Sidebar>}>
34
+ <p>Page</p>
35
+ </AppShell>,
36
+ )
37
+ const { unmount } = frame('solid')
38
+ expect(screen.getByRole('navigation').className).toContain('border-r')
39
+ expect(screen.getByRole('banner').className).toContain('border-b')
40
+ unmount()
41
+ frame('inset')
42
+ expect(screen.getByRole('navigation').className).not.toMatch(/border-r|bg-bg-surface/)
43
+ expect(screen.getByRole('banner').className).not.toMatch(/border-b|bg-bg-surface/)
44
+ const panel = screen.getByRole('main').closest('.rounded-lg')
45
+ expect(panel?.className).toContain('bg-bg-surface')
46
+ expect(panel?.className).toContain('mr-4')
47
+ })
48
+
29
49
  it('leaves the floating manner’s card as it was', () => {
30
50
  render(
31
51
  <AppShell variant="floating" nav={<nav>Nav</nav>}>
package/src/AppShell.tsx CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
+ import { Card } from './Card'
3
4
  import { ScrollArea } from './ScrollArea'
5
+ import { SidebarOnGround } from './Sidebar'
4
6
  import { TopBar } from './TopBar'
5
7
 
6
8
  /**
7
- * The frame of an app, in the two manners that pair with the TopBar's
9
+ * The frame of an app, in the manners that pair with the TopBar's
8
10
  * (Katerina, 2026-09-02: the two shells go together — the solid bar with
9
11
  * the sidebar, the floating bar with the rail):
10
12
  *
@@ -15,6 +17,9 @@ import { TopBar } from './TopBar'
15
17
  * edge, the rail stands on the app background, and the content lives
16
18
  * in the rounded card beside it (2026-09-02, the floating app's frame
17
19
  * geometry; its collapse animation and launcher stay in the app).
20
+ * - `inset` — the structured frame, the other way round: the TopBar and
21
+ * the Sidebar stand on the ground, and the content is a lighter card
22
+ * beside them (Katerina, 28 September: Linear's layout, in any theme).
18
23
  *
19
24
  * The banner belongs to the content area (her ruling, 2026-09-02): it
20
25
  * renders at the top of the main column — inside the card, in the
@@ -24,7 +29,7 @@ import { TopBar } from './TopBar'
24
29
  * has no data and no routes.
25
30
  */
26
31
  export interface AppShellProps {
27
- variant?: 'solid' | 'floating'
32
+ variant?: 'solid' | 'floating' | 'inset'
28
33
  /** The top bar's menu button, in an app whose navigation collapses. */
29
34
  menu?: ReactNode
30
35
  /** The top bar's left: the app's logo mark, or its name as text. */
@@ -42,6 +47,7 @@ export interface AppShellProps {
42
47
 
43
48
  export function AppShell({ variant = 'solid', menu, logo, search, identity, banner, nav, children }: AppShellProps) {
44
49
  const bar = <TopBar variant={variant} menu={menu} logo={logo} search={search} right={identity} />
50
+ const inset = variant === 'inset'
45
51
 
46
52
  if (variant === 'floating') {
47
53
  return (
@@ -66,6 +72,30 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
66
72
  )
67
73
  }
68
74
 
75
+ const column = (
76
+ <>
77
+ {banner}
78
+ {/* The frame owns the page's scrollbar (Katerina, 2026-09-09: the
79
+ Issues page still had a native bar — D40 said everywhere, and the
80
+ one place every page passes through had been left out). A
81
+ `ScrollArea` here means no page can forget it. The content box is
82
+ at least the viewport's height and grows with a tall page, so Base
83
+ UI sees its size change (the morning's phantom bar was a content
84
+ box it could not watch); `main` fills it as a flex column. The
85
+ page contract (the AppShell page says it): a page is a flex
86
+ child of `main` — `flex-1` to fill and scroll here; `flex-1
87
+ min-h-0 [contain:size]` to take exactly the frame's height and
88
+ scroll inside itself, as Ship's detail grids do — and the outer
89
+ region, with nothing to scroll, passes the wheel through. Never
90
+ `h-full`: a box that is *at least* the viewport's height gives a
91
+ percentage nothing to resolve against — measured, all four Ship
92
+ pages, 2026-09-09. */}
93
+ <ScrollArea className="min-h-0 flex-1" contentClassName="flex min-h-full flex-col">
94
+ <main className="flex min-w-0 flex-1 flex-col">{children}</main>
95
+ </ScrollArea>
96
+ </>
97
+ )
98
+
69
99
  return (
70
100
  /* `relative overflow-hidden` is the seal the floating manner already has
71
101
  (and the ScrollArea's own root is `relative`, so an absolutely placed
@@ -79,28 +109,17 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
79
109
  <div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-bg-base text-text-primary">
80
110
  {bar}
81
111
  <div className="flex min-h-0 flex-1">
82
- {nav}
83
- <div className="flex min-h-0 min-w-0 flex-1 flex-col">
84
- {banner}
85
- {/* The frame owns the page's scrollbar (Katerina, 2026-09-09: the
86
- Issues page still had a native bar — D40 said everywhere, and the
87
- one place every page passes through had been left out). A
88
- `ScrollArea` here means no page can forget it. The content box is
89
- at least the viewport's height and grows with a tall page, so Base
90
- UI sees its size change (the morning's phantom bar was a content
91
- box it could not watch); `main` fills it as a flex column. The
92
- page contract (the AppShell page says it): a page is a flex
93
- child of `main` — `flex-1` to fill and scroll here; `flex-1
94
- min-h-0 [contain:size]` to take exactly the frame's height and
95
- scroll inside itself, as Ship's detail grids do — and the outer
96
- region, with nothing to scroll, passes the wheel through. Never
97
- `h-full`: a box that is *at least* the viewport's height gives a
98
- percentage nothing to resolve against — measured, all four Ship
99
- pages, 2026-09-09. */}
100
- <ScrollArea className="min-h-0 flex-1" contentClassName="flex min-h-full flex-col">
101
- <main className="flex min-w-0 flex-1 flex-col">{children}</main>
102
- </ScrollArea>
103
- </div>
112
+ <SidebarOnGround.Provider value={inset}>{nav}</SidebarOnGround.Provider>
113
+ {/* `inset`: the content is a card on the ground, the Card's own surface look,
114
+ 16px from the right and bottom as the floating card is, 8px corners (Katerina,
115
+ 28 September). */}
116
+ {inset ? (
117
+ <Card fill="surface" clip className="mb-4 mr-4 flex min-h-0 min-w-0 flex-1 flex-col">
118
+ {column}
119
+ </Card>
120
+ ) : (
121
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">{column}</div>
122
+ )}
104
123
  </div>
105
124
  </div>
106
125
  )
@@ -0,0 +1,79 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as RichTextStories from './RichText.stories'
3
+
4
+ <Meta of={RichTextStories} />
5
+
6
+ # RichText
7
+
8
+ Formatted text, drawn from a parsed tree: headings, paragraphs, both lists, a
9
+ quote, code, a divider, a table and links, in one look for every app.
10
+
11
+ <Canvas of={RichTextStories.EveryBlock} />
12
+
13
+ ## When
14
+
15
+ - **`default`** — text someone reads: a post, a reply, a description.
16
+ - **`small`** — the same text one step down, with every heading, list, quote
17
+ and table kept: an item shown inside a card.
18
+ - `renderReference` — a run that stands for an address: the app draws what it
19
+ points at, a person or an item.
20
+ - `renderText` — words the app recognises in plain text, drawn as chips.
21
+ - `renderBlock` — a block this part has no design for, such as an attachment.
22
+
23
+ ## When not
24
+
25
+ - One word standing for a person or a thing, inside your own sentence →
26
+ **InlineChip**.
27
+ - One link inside your own sentence → **Link**.
28
+ - A value set out as a label and its value → **Property**.
29
+
30
+ ## How
31
+
32
+ ```tsx
33
+ import { InlineChip, RichText } from '@estiva-app/ui'
34
+
35
+ // toRenderTree comes from @estiva-app/protocol
36
+ <RichText
37
+ blocks={toRenderTree(value, format)}
38
+ renderReference={(reference) => <InlineChip tone="person">{nameOf(reference)}</InlineChip>}
39
+ />
40
+ ```
41
+
42
+ - **It takes the tree, not the text.** Parse first; the format comes from the
43
+ item's tag, never from its body. The protocol's `RenderBlock` fits
44
+ `RichTextBlock`. To draw a block somewhere else (below the text, as a card),
45
+ leave it out of `blocks`.
46
+ - **The colour is set here**: the text is `text-secondary`, and headings
47
+ of level 1 and 2 are `text-primary`. Do not wrap it to recolour it.
48
+ - **Links are found here**, in every run that is not code: `[words](address)`
49
+ and a bare `https://` address. Both open in a new tab, and a click on one
50
+ is not also a click on what the text sits in, such as a card. `renderText`
51
+ gets only the text between links, so an address is never read as a name.
52
+ - **Nothing is set as HTML.** Every string reaches the page as text; a `<b>`
53
+ typed by an author shows as `<b>`.
54
+ - A block of a kind it does not know shows its words and its children. It is
55
+ never dropped.
56
+ - Code, in a sentence or as a block, is 0.9 of the text around it, so it
57
+ shrinks with `small`. A long line in a block wraps; nothing scrolls
58
+ sideways.
59
+ - Each block carries its `id` as `data-block-id`, for an app that points at
60
+ one block.
61
+ - **An editor wears the same look.** Put `richTextClassName()` (or
62
+ `richTextClassName('small')`) on the editor's root, so what is being written
63
+ looks exactly like what is read. It styles the elements inside the box,
64
+ including the paragraph an editor puts inside a list item, a quote and a
65
+ table cell.
66
+
67
+ ```tsx
68
+ import { richTextClassName } from '@estiva-app/ui'
69
+
70
+ const editorAttributes = { class: richTextClassName('default', 'outline-none') }
71
+ ```
72
+
73
+ ## What it owns
74
+
75
+ Nothing. It only draws. Clicks and keys are yours.
76
+
77
+ ## Props
78
+
79
+ <Controls of={RichTextStories.EveryBlock} />
@@ -0,0 +1,91 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSquareRounded } from '@tabler/icons-react'
3
+ import { RichText, type RichTextBlock, type RichTextRun } from './RichText'
4
+ import { InlineChip } from './InlineChip'
5
+ import { Card } from './Card'
6
+
7
+ const t = (text: string, ...marks: string[]): RichTextRun => ({ text, marks })
8
+ const p = (...inline: RichTextRun[]): RichTextBlock => ({ type: 'paragraph', inline })
9
+ const items = (...texts: string[]): RichTextBlock[] => texts.map((text) => ({ type: 'listItem', inline: [t(text)] }))
10
+ const cell = (type: 'tableHeader' | 'tableCell', text: string): RichTextBlock => ({ type: 'unknown', typeName: type, children: [p(t(text))] })
11
+ const row = (type: 'tableHeader' | 'tableCell', ...texts: string[]): RichTextBlock => ({ type: 'unknown', typeName: 'tableRow', children: texts.map((x) => cell(type, x)) })
12
+
13
+ /** Every block the part draws, in the order a reader meets them. */
14
+ const EVERY_BLOCK: RichTextBlock[] = [
15
+ { type: 'heading', level: 1, inline: [t('A heading')] },
16
+ { type: 'heading', level: 2, inline: [t('A smaller heading')] },
17
+ { type: 'heading', level: 3, inline: [t('A third level, in the text colour')] },
18
+ p(t('Text can be '), t('bold', 'bold'), t(', '), t('italic', 'italic'), t(' or '), t('underlined', 'underline'), t(', and hold '), t('code', 'code'), t(' in a sentence.\nA line break stays a line break.')),
19
+ { type: 'bulletList', children: items('An item', 'Another item') },
20
+ { type: 'orderedList', children: items('The first step', 'The second step') },
21
+ { type: 'blockquote', inline: [t('A quote, set in from a line.')] },
22
+ { type: 'codeBlock', language: 'ts', inline: [t('const item = 1 // a long line of code folds onto the next line instead of scrolling sideways')] },
23
+ { type: 'horizontalRule' },
24
+ {
25
+ type: 'table',
26
+ children: [row('tableHeader', 'Label', 'Value'), row('tableCell', 'Item one', 'One'), row('tableCell', 'Item two', 'Two')],
27
+ },
28
+ ]
29
+
30
+ const meta = {
31
+ title: 'Components/RichText',
32
+ component: RichText,
33
+ args: { blocks: EVERY_BLOCK, size: 'default' },
34
+ argTypes: { size: { control: 'inline-radio', options: ['default', 'small'] } },
35
+ decorators: [(Story) => <div className="w-[480px]"><Story /></div>],
36
+ } satisfies Meta<typeof RichText>
37
+
38
+ export default meta
39
+ type Story = StoryObj<typeof meta>
40
+
41
+ /** Every block: headings, text and its marks, both lists, a quote, code, a divider and a table. */
42
+ export const EveryBlock: Story = {}
43
+
44
+ /** One step down, with every heading, list, quote and table kept: an item shown inside a card. */
45
+ export const Small: Story = {
46
+ args: { size: 'small' },
47
+ render: (args) => (
48
+ <Card className="p-3">
49
+ <RichText {...args} />
50
+ </Card>
51
+ ),
52
+ }
53
+
54
+ /** A link written with its own words, and a bare address, which may break anywhere. Code is never read for links. */
55
+ export const Links: Story = {
56
+ args: {
57
+ blocks: [
58
+ p(t('A link with its own words: [the guide](https://example.com/guide). A bare address: https://example.com/a/long/address/that/breaks/where/it/must.')),
59
+ p(t('Inside code it stays text: '), t('[not a link](https://example.com)', 'code')),
60
+ ],
61
+ },
62
+ }
63
+
64
+ /** What an app draws itself: a run that stands for an address, and words it recognises in plain text. */
65
+ export const WithTheAppsOwnParts: Story = {
66
+ args: {
67
+ blocks: [
68
+ p(
69
+ t('Assigned to '),
70
+ { text: 'nostr:npub1example', marks: [], reference: 'nostr:npub1example' },
71
+ t(', see [[Item one]] for the rest.'),
72
+ ),
73
+ ],
74
+ renderReference: () => <InlineChip tone="person">Name</InlineChip>,
75
+ renderText: (text) =>
76
+ text.split(/(\[\[[^\]]+\]\])/).map((part, i) =>
77
+ part.startsWith('[[') ? (
78
+ <InlineChip key={i} icon={<IconSquareRounded size={16} stroke={1.5} />}>{part.slice(2, -2)}</InlineChip>
79
+ ) : (
80
+ part
81
+ ),
82
+ ),
83
+ },
84
+ }
85
+
86
+ /** A block this part has no design for, from a later version: its words are drawn, never dropped. */
87
+ export const UnknownBlock: Story = {
88
+ args: {
89
+ blocks: [p(t('The block below is of a kind this part does not know.')), { type: 'unknown', typeName: 'later', inline: [t('Its words are still here.')] }],
90
+ },
91
+ }