mosage 0.1.0 โ†’ 0.8.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 (146) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +16 -217
  3. package/bin.js +2 -0
  4. package/dist/build-C7NW_3Pk.js +14 -0
  5. package/dist/check-CP4873Wx.js +41 -0
  6. package/dist/cli/bin.d.ts +1 -0
  7. package/dist/cli/bin.js +228 -0
  8. package/dist/config-DPm1BBAb.js +2619 -0
  9. package/dist/config-TlTe7Ona.d.ts +24 -0
  10. package/dist/context-BqsdSrAQ.js +1084 -0
  11. package/dist/dev-Biz42qlu.js +17 -0
  12. package/dist/diagram-xlVDekYk.js +763 -0
  13. package/dist/export-Bi6nuxjT.js +31 -0
  14. package/dist/import-D2jNB07F.js +25 -0
  15. package/dist/index.d.ts +455 -0
  16. package/dist/index.js +693 -0
  17. package/dist/init-Bbtj2pxF.js +262 -0
  18. package/dist/preview-CLm51aRt.js +19 -0
  19. package/dist/sdk-DjpX6mCv.js +51 -0
  20. package/dist/vite/index.d.ts +25 -0
  21. package/dist/vite/index.js +2 -0
  22. package/env.d.ts +83 -0
  23. package/package.json +84 -64
  24. package/skills/apply-comments/SKILL.md +43 -56
  25. package/skills/create-doc/SKILL.md +106 -0
  26. package/skills/create-theme/SKILL.md +184 -0
  27. package/skills/current-doc/SKILL.md +120 -0
  28. package/skills/doc-authoring/SKILL.md +434 -0
  29. package/skills/doc-authoring/references/assets.md +47 -0
  30. package/skills/doc-authoring/references/design-system.md +81 -0
  31. package/skills/doc-authoring/references/long-form.md +131 -0
  32. package/skills/doc-authoring/references/pagination.md +118 -0
  33. package/skills/doc-authoring/references/tables-and-charts.md +161 -0
  34. package/src/app/app.tsx +42 -0
  35. package/src/app/components/data-table.tsx +196 -0
  36. package/src/app/components/design-panel/design-panel.tsx +318 -0
  37. package/src/app/components/design-panel/design-provider.tsx +121 -0
  38. package/src/app/components/design-panel/use-design.ts +85 -0
  39. package/src/app/components/diagram.tsx +76 -0
  40. package/src/app/components/doc-assets.tsx +129 -0
  41. package/src/app/components/doc-search.tsx +248 -0
  42. package/src/app/components/doc-sidebar.tsx +162 -0
  43. package/src/app/components/flow-page.tsx +93 -0
  44. package/src/app/components/footnote.tsx +204 -0
  45. package/src/app/components/image-placeholder.tsx +50 -0
  46. package/src/app/components/inspector/inspector.tsx +518 -0
  47. package/src/app/components/numbering.tsx +224 -0
  48. package/src/app/components/page-frame.tsx +70 -0
  49. package/src/app/components/sidebar/folder-item.tsx +212 -0
  50. package/src/app/components/sidebar/icon-picker.tsx +99 -0
  51. package/src/app/components/sidebar/sidebar.tsx +252 -0
  52. package/src/app/components/table-of-contents.tsx +93 -0
  53. package/src/app/components/theme-toggle.tsx +50 -0
  54. package/src/app/components/themes/markdown.tsx +249 -0
  55. package/src/app/components/themes/theme-preview.tsx +74 -0
  56. package/src/app/components/ui/menu.tsx +143 -0
  57. package/src/app/index.html +12 -0
  58. package/src/app/lib/agent-bridge.ts +140 -0
  59. package/src/app/lib/assets.ts +151 -0
  60. package/src/app/lib/design-presets.ts +109 -0
  61. package/src/app/lib/design.ts +88 -0
  62. package/src/app/lib/diagnostics.ts +282 -0
  63. package/src/app/lib/doc-preview.tsx +29 -0
  64. package/src/app/lib/docs.ts +26 -0
  65. package/src/app/lib/docx/extract.ts +1623 -0
  66. package/src/app/lib/docx/fonts.test.ts +136 -0
  67. package/src/app/lib/docx/fonts.ts +166 -0
  68. package/src/app/lib/docx/media.ts +102 -0
  69. package/src/app/lib/docx/model.ts +206 -0
  70. package/src/app/lib/docx/paragraph.test.ts +92 -0
  71. package/src/app/lib/docx/paragraph.ts +107 -0
  72. package/src/app/lib/docx/props.ts +187 -0
  73. package/src/app/lib/docx/styles.ts +306 -0
  74. package/src/app/lib/docx/units.ts +35 -0
  75. package/src/app/lib/docx/write.test.ts +507 -0
  76. package/src/app/lib/docx/write.ts +581 -0
  77. package/src/app/lib/docx/xml.ts +39 -0
  78. package/src/app/lib/export-docx.ts +289 -0
  79. package/src/app/lib/export-dom.ts +318 -0
  80. package/src/app/lib/export-html.ts +156 -0
  81. package/src/app/lib/export-image.ts +70 -0
  82. package/src/app/lib/export-pdf.ts +165 -0
  83. package/src/app/lib/flow-measure.test.ts +31 -0
  84. package/src/app/lib/flow-measure.ts +183 -0
  85. package/src/app/lib/flow.test.ts +110 -0
  86. package/src/app/lib/flow.ts +136 -0
  87. package/src/app/lib/folders.ts +192 -0
  88. package/src/app/lib/footnotes.test.tsx +102 -0
  89. package/src/app/lib/footnotes.ts +94 -0
  90. package/src/app/lib/inspector/fiber.ts +99 -0
  91. package/src/app/lib/labels.test.ts +18 -0
  92. package/src/app/lib/labels.ts +181 -0
  93. package/src/app/lib/outline.ts +118 -0
  94. package/src/app/lib/page-context.tsx +43 -0
  95. package/src/app/lib/page-range.test.ts +95 -0
  96. package/src/app/lib/page-range.ts +90 -0
  97. package/src/app/lib/print-ready.ts +69 -0
  98. package/src/app/lib/rasterize.ts +173 -0
  99. package/src/app/lib/scan.ts +26 -0
  100. package/src/app/lib/sdk.test.ts +32 -0
  101. package/src/app/lib/sdk.ts +115 -0
  102. package/src/app/lib/themes.ts +31 -0
  103. package/src/app/lib/use-doc-module.ts +53 -0
  104. package/src/app/lib/use-doc-pages.ts +147 -0
  105. package/src/app/lib/utils.ts +6 -0
  106. package/src/app/lib/view-mode.test.ts +91 -0
  107. package/src/app/lib/view-mode.ts +104 -0
  108. package/src/app/main.tsx +14 -0
  109. package/src/app/routes/assets.tsx +257 -0
  110. package/src/app/routes/doc.tsx +877 -0
  111. package/src/app/routes/home-shell.tsx +203 -0
  112. package/src/app/routes/home.tsx +269 -0
  113. package/src/app/routes/themes.tsx +121 -0
  114. package/src/app/styles.css +97 -0
  115. package/src/app/virtual.d.ts +30 -0
  116. package/template/AGENTS.md +27 -0
  117. package/template/README.md +39 -0
  118. package/template/docs/getting-started/index.tsx +230 -0
  119. package/template/mosage.config.ts +5 -0
  120. package/template/package.json +24 -0
  121. package/template/tsconfig.json +17 -0
  122. package/README.en.md +0 -57
  123. package/dist/cli.js +0 -4545
  124. package/dist/web/assets/index-Czg2WeHe.js +0 -182
  125. package/dist/web/assets/index-DKAyt92W.css +0 -1
  126. package/dist/web/index.html +0 -15
  127. package/skills/current-position/SKILL.md +0 -65
  128. package/skills/kickoff/SKILL.md +0 -105
  129. package/skills/mosage-reference/SKILL.md +0 -164
  130. package/skills/outline/SKILL.md +0 -79
  131. package/skills/review/SKILL.md +0 -64
  132. package/skills/write-chapter/SKILL.md +0 -58
  133. package/template/book/STYLE.md +0 -6
  134. package/template/book/assets/.gitkeep +0 -0
  135. package/template/book/book.yaml +0 -42
  136. package/template/book/brief.md +0 -6
  137. package/template/book/chapters/.gitkeep +0 -0
  138. package/template/book/notes/README.md +0 -7
  139. package/template/project/AGENTS.md +0 -82
  140. package/template/project/CLAUDE.md +0 -1
  141. package/template/project/README.md +0 -48
  142. package/template/project/books/.gitkeep +0 -0
  143. package/template/project/gitignore +0 -5
  144. package/template/project/mosage.yaml +0 -23
  145. package/template/project/notes/README.md +0 -8
  146. package/template/project/package.json +0 -14
@@ -0,0 +1,224 @@
1
+ import { type CSSProperties, type ReactNode, useId } from 'react';
2
+ import {
3
+ LABEL_ATTR,
4
+ LABEL_ID_ATTR,
5
+ LABEL_TEXT_ATTR,
6
+ type LabelKind,
7
+ onPageParts,
8
+ useDocLabel,
9
+ useDocLabels,
10
+ useLabelVocabulary,
11
+ } from '../lib/labels';
12
+ import { useDocPageNumber } from '../lib/page-context';
13
+
14
+ export const REF_ATTR = 'data-od-ref';
15
+ /** The page number inside a reference's page suffix โ€” the Word export makes it a PAGEREF. */
16
+ export const REF_PAGE_ATTR = 'data-od-ref-page';
17
+ export const LIST_OF_ATTR = 'data-od-list-of';
18
+
19
+ function nameFor(kind: LabelKind, vocabulary: { figure: string; table: string }): string {
20
+ return kind === 'table' ? vocabulary.table : vocabulary.figure;
21
+ }
22
+
23
+ export type FigureProps = {
24
+ /** Stable id, so `<Ref to>` can point at it. Generated when omitted. */
25
+ id?: string;
26
+ /** What the caption says after the number. */
27
+ caption?: ReactNode;
28
+ /** `figure` numbers with figures, `table` with tables. Defaults to `figure`. */
29
+ kind?: LabelKind;
30
+ /** Tables conventionally caption above, figures below. */
31
+ captionPosition?: 'above' | 'below';
32
+ /** Plain-text caption for the list of figures, when `caption` carries markup. */
33
+ captionText?: string;
34
+ style?: CSSProperties;
35
+ captionStyle?: CSSProperties;
36
+ className?: string;
37
+ children?: ReactNode;
38
+ };
39
+
40
+ /**
41
+ * A numbered figure or table. The number comes from a scan of the rendered
42
+ * pages, not from a counter in the source, so inserting a figure halfway
43
+ * through a document renumbers everything after it โ€” including the references
44
+ * to it โ€” without anyone editing a number.
45
+ *
46
+ * Caption and content are one block, so a `flow()` section never separates them.
47
+ */
48
+ export function Figure({
49
+ id,
50
+ caption,
51
+ kind = 'figure',
52
+ captionPosition,
53
+ captionText,
54
+ style,
55
+ captionStyle,
56
+ className,
57
+ children,
58
+ }: FigureProps) {
59
+ const generated = useId();
60
+ const labelId = id ?? generated;
61
+ const entry = useDocLabel(labelId);
62
+ const vocabulary = useLabelVocabulary();
63
+ const position = captionPosition ?? (kind === 'table' ? 'above' : 'below');
64
+
65
+ const text =
66
+ captionText ??
67
+ (typeof caption === 'string' || typeof caption === 'number' ? String(caption) : '');
68
+
69
+ const captionNode = caption ? (
70
+ <figcaption
71
+ style={{
72
+ fontSize: 'var(--od-size-caption)',
73
+ color: 'var(--od-muted)',
74
+ lineHeight: 1.4,
75
+ margin: position === 'above' ? '0 0 6px' : '6px 0 0',
76
+ ...captionStyle,
77
+ }}
78
+ >
79
+ <span style={{ fontWeight: 600, color: 'var(--od-text)' }}>
80
+ {nameFor(kind, vocabulary)} {entry?.number ?? ''}
81
+ </span>
82
+ {caption ? <span> โ€” {caption}</span> : null}
83
+ </figcaption>
84
+ ) : null;
85
+
86
+ return (
87
+ <figure
88
+ {...{
89
+ [LABEL_ATTR]: kind,
90
+ [LABEL_ID_ATTR]: labelId,
91
+ [LABEL_TEXT_ATTR]: text,
92
+ }}
93
+ className={className}
94
+ style={{ margin: '0 0 16px', ...style }}
95
+ >
96
+ {position === 'above' && captionNode}
97
+ {children}
98
+ {position === 'below' && captionNode}
99
+ </figure>
100
+ );
101
+ }
102
+
103
+ export type RefProps = {
104
+ /** The `id` of a `<Figure>` or `<Footnote>`. */
105
+ to: string;
106
+ /**
107
+ * Append the page. `auto` (the default) adds it only when the target sits on
108
+ * another sheet, which is the only time a reader needs it.
109
+ */
110
+ showPage?: boolean | 'auto';
111
+ style?: CSSProperties;
112
+ className?: string;
113
+ };
114
+
115
+ /**
116
+ * A cross-reference that resolves after layout: "Figure 3", plus the page when
117
+ * the target is elsewhere. Never write "see Figure 3 on page 12" by hand โ€” both
118
+ * numbers move.
119
+ */
120
+ export function Ref({ to, showPage = 'auto', style, className }: RefProps) {
121
+ const entry = useDocLabel(to);
122
+ const vocabulary = useLabelVocabulary();
123
+ const here = useDocPageNumber();
124
+
125
+ if (!entry) {
126
+ // Visible on the page and reported by `mosage check`: a reference that
127
+ // resolves to nothing is a broken document, not a blank space.
128
+ return (
129
+ <span
130
+ data-od-ref-unresolved={to}
131
+ className={className}
132
+ style={{ color: 'var(--od-accent)', ...style }}
133
+ >
134
+ [?{to}]
135
+ </span>
136
+ );
137
+ }
138
+
139
+ const name = entry.kind === 'footnote' ? '' : `${nameFor(entry.kind, vocabulary)} `;
140
+ const withPage = showPage === 'auto' ? entry.page !== here : showPage;
141
+ const parts = onPageParts(vocabulary);
142
+
143
+ return (
144
+ <span {...{ [REF_ATTR]: to }} className={className} style={style}>
145
+ {name}
146
+ {entry.number}
147
+ {withPage && parts && (
148
+ <>
149
+ {parts[0]}
150
+ <span {...{ [REF_PAGE_ATTR]: '' }}>{entry.page}</span>
151
+ {parts[1]}
152
+ </>
153
+ )}
154
+ {withPage && !parts && vocabulary.onPage}
155
+ </span>
156
+ );
157
+ }
158
+
159
+ export type ListOfProps = {
160
+ kind?: LabelKind;
161
+ /** Show the page number column with dot leaders. Defaults to true. */
162
+ showPageNumbers?: boolean;
163
+ style?: CSSProperties;
164
+ className?: string;
165
+ };
166
+
167
+ /**
168
+ * The document's figures or tables as a contents list. Same lifecycle as
169
+ * `<TableOfContents>`: empty on the first render pass, filled once the scan has
170
+ * run โ€” including before an export serializes.
171
+ */
172
+ export function ListOf({ kind = 'figure', showPageNumbers = true, style, className }: ListOfProps) {
173
+ const entries = useDocLabels(kind);
174
+ const vocabulary = useLabelVocabulary();
175
+
176
+ return (
177
+ <div
178
+ {...{ [LIST_OF_ATTR]: kind }}
179
+ className={className}
180
+ style={{
181
+ fontFamily: 'var(--od-font-body)',
182
+ fontSize: 'var(--od-size-body)',
183
+ color: 'var(--od-text)',
184
+ ...style,
185
+ }}
186
+ >
187
+ {entries.map((entry) => (
188
+ <div
189
+ key={entry.id}
190
+ {...{ [REF_ATTR]: entry.id }}
191
+ style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 6 }}
192
+ >
193
+ <span style={{ flex: 'none', color: 'var(--od-muted)' }}>
194
+ {nameFor(kind, vocabulary)} {entry.number}
195
+ </span>
196
+ <span>{entry.text}</span>
197
+ {showPageNumbers && (
198
+ <>
199
+ <span
200
+ aria-hidden
201
+ style={{
202
+ flex: 1,
203
+ borderBottom: '1px dotted var(--od-rule)',
204
+ transform: 'translateY(-3px)',
205
+ }}
206
+ />
207
+ <span {...{ [REF_PAGE_ATTR]: '' }} style={{ fontVariantNumeric: 'tabular-nums' }}>
208
+ {entry.page}
209
+ </span>
210
+ </>
211
+ )}
212
+ </div>
213
+ ))}
214
+ </div>
215
+ );
216
+ }
217
+
218
+ export function ListOfFigures(props: Omit<ListOfProps, 'kind'>) {
219
+ return <ListOf {...props} kind="figure" />;
220
+ }
221
+
222
+ export function ListOfTables(props: Omit<ListOfProps, 'kind'>) {
223
+ return <ListOf {...props} kind="table" />;
224
+ }
@@ -0,0 +1,70 @@
1
+ import type { CSSProperties, ReactNode } from 'react';
2
+ import { cn } from '@/lib/utils';
3
+ import { type DesignSystem, designToCssVars } from '../lib/design';
4
+ import { PAGE_ATTR, PAGE_INDEX_ATTR } from '../lib/outline';
5
+ import { DocPageProvider } from '../lib/page-context';
6
+ import type { PageGeometry } from '../lib/sdk';
7
+
8
+ type Props = {
9
+ index: number;
10
+ total: number;
11
+ geometry: PageGeometry;
12
+ scale: number;
13
+ design?: DesignSystem;
14
+ flat?: boolean;
15
+ className?: string;
16
+ children: ReactNode;
17
+ };
18
+
19
+ /**
20
+ * One sheet of paper. The inner node always lays out at the page's true pixel
21
+ * size โ€” authors write absolute px against A4 โ€” and only the wrapper shrinks,
22
+ * so a page looks identical on screen, in the PDF, and in the HTML export.
23
+ */
24
+ export function PageFrame({
25
+ index,
26
+ total,
27
+ geometry,
28
+ scale,
29
+ design,
30
+ flat = false,
31
+ className,
32
+ children,
33
+ }: Props) {
34
+ const designVars = design ? designToCssVars(design) : undefined;
35
+
36
+ return (
37
+ <div
38
+ className={cn(
39
+ 'relative flex-none overflow-hidden bg-white text-black',
40
+ !flat && 'shadow-[0_1px_3px_rgba(0,0,0,.28),0_10px_28px_-6px_rgba(0,0,0,.28)]',
41
+ className,
42
+ )}
43
+ style={{ width: geometry.width * scale, height: geometry.height * scale }}
44
+ >
45
+ <div
46
+ {...{ [PAGE_ATTR]: '', [PAGE_INDEX_ATTR]: index }}
47
+ style={
48
+ {
49
+ width: geometry.width,
50
+ height: geometry.height,
51
+ transform: `scale(${scale})`,
52
+ transformOrigin: 'top left',
53
+ overflow: 'hidden',
54
+ position: 'relative',
55
+ // A page must render identically wherever it is embedded. The
56
+ // thumbnail rail wraps pages in a <button>, whose default
57
+ // `text-align: center` would otherwise leak into the content.
58
+ textAlign: 'start',
59
+ ...(designVars ?? {}),
60
+ ...(designVars ? { background: 'var(--od-bg)', color: 'var(--od-text)' } : {}),
61
+ } as CSSProperties
62
+ }
63
+ >
64
+ <DocPageProvider index={index} total={total}>
65
+ {children}
66
+ </DocPageProvider>
67
+ </div>
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,212 @@
1
+ import { FileText, Image, MoreHorizontal, Palette, PencilLine, Trash2 } from 'lucide-react';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import type { Folder, FolderIcon } from '../../lib/sdk';
4
+ import { cn } from '../../lib/utils';
5
+ import { Menu, MenuItem, MenuSeparator } from '../ui/menu';
6
+ import { IconPicker } from './icon-picker';
7
+
8
+ export const DOC_DND_MIME = 'application/x-mosage-id';
9
+
10
+ export type FolderRow =
11
+ | { kind: 'all' }
12
+ | { kind: 'themes' }
13
+ | { kind: 'assets' }
14
+ | { kind: 'draft' }
15
+ | {
16
+ kind: 'folder';
17
+ folder: Folder;
18
+ onRename: (name: string) => void;
19
+ onChangeIcon: (icon: FolderIcon) => void;
20
+ onDelete: () => void;
21
+ };
22
+
23
+ export function FolderIconChip({ icon }: { icon: FolderIcon }) {
24
+ if (icon.type === 'emoji') {
25
+ return (
26
+ <span className="grid size-5 shrink-0 place-items-center text-[13px] leading-none">
27
+ {icon.value}
28
+ </span>
29
+ );
30
+ }
31
+ return (
32
+ <span
33
+ className="size-3 shrink-0 rounded-[3px] ring-1 ring-black/10"
34
+ style={{ background: icon.value }}
35
+ />
36
+ );
37
+ }
38
+
39
+ const STATIC_ICON = {
40
+ all: FileText,
41
+ themes: Palette,
42
+ assets: Image,
43
+ } as const;
44
+
45
+ export function FolderItem({
46
+ row,
47
+ count,
48
+ selected,
49
+ onSelect,
50
+ onDropDoc,
51
+ }: {
52
+ row: FolderRow;
53
+ count: number;
54
+ selected: boolean;
55
+ onSelect: () => void;
56
+ onDropDoc: (docId: string) => void;
57
+ }) {
58
+ const [renaming, setRenaming] = useState(false);
59
+ const [draft, setDraft] = useState('');
60
+ const [dragOver, setDragOver] = useState(false);
61
+ const inputRef = useRef<HTMLInputElement>(null);
62
+
63
+ useEffect(() => {
64
+ if (renaming) inputRef.current?.select();
65
+ }, [renaming]);
66
+
67
+ const label =
68
+ row.kind === 'folder'
69
+ ? row.folder.name
70
+ : row.kind === 'all'
71
+ ? 'Documents'
72
+ : row.kind === 'themes'
73
+ ? 'Themes'
74
+ : row.kind === 'assets'
75
+ ? 'Assets'
76
+ : 'Unfiled';
77
+
78
+ const acceptsDrop = row.kind === 'folder' || row.kind === 'draft';
79
+
80
+ const commitRename = () => {
81
+ if (row.kind !== 'folder') return;
82
+ const next = draft.trim();
83
+ setRenaming(false);
84
+ if (next && next !== row.folder.name) row.onRename(next);
85
+ };
86
+
87
+ return (
88
+ // biome-ignore lint/a11y/noStaticElementInteractions: drop target wraps the button, which stays the keyboard path
89
+ <div
90
+ className={cn(
91
+ 'group relative flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors',
92
+ selected ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/60',
93
+ dragOver && 'ring-1 ring-foreground/40',
94
+ )}
95
+ onDragOver={(e) => {
96
+ if (!acceptsDrop || !e.dataTransfer.types.includes(DOC_DND_MIME)) return;
97
+ e.preventDefault();
98
+ e.dataTransfer.dropEffect = 'move';
99
+ setDragOver(true);
100
+ }}
101
+ onDragLeave={() => setDragOver(false)}
102
+ onDrop={(e) => {
103
+ if (!acceptsDrop) return;
104
+ const docId = e.dataTransfer.getData(DOC_DND_MIME);
105
+ setDragOver(false);
106
+ if (!docId) return;
107
+ e.preventDefault();
108
+ onDropDoc(docId);
109
+ }}
110
+ >
111
+ {row.kind === 'folder' ? (
112
+ <Menu
113
+ placement="right-start"
114
+ trigger={(props) => (
115
+ <button
116
+ type="button"
117
+ aria-label="Change icon"
118
+ className="shrink-0 transition-transform hover:scale-110"
119
+ {...props}
120
+ >
121
+ <FolderIconChip icon={row.folder.icon} />
122
+ </button>
123
+ )}
124
+ >
125
+ {() => (
126
+ <div className="p-1">
127
+ <IconPicker value={row.folder.icon} onChange={row.onChangeIcon} />
128
+ </div>
129
+ )}
130
+ </Menu>
131
+ ) : row.kind === 'draft' ? (
132
+ <FolderIconChip icon={{ type: 'color', value: '#9ca3af' }} />
133
+ ) : (
134
+ (() => {
135
+ const Icon = STATIC_ICON[row.kind];
136
+ return <Icon className="size-3.5 shrink-0" />;
137
+ })()
138
+ )}
139
+
140
+ {renaming && row.kind === 'folder' ? (
141
+ <input
142
+ ref={inputRef}
143
+ value={draft}
144
+ onChange={(e) => setDraft(e.target.value)}
145
+ onBlur={commitRename}
146
+ onKeyDown={(e) => {
147
+ if (e.nativeEvent.isComposing) return;
148
+ if (e.key === 'Enter') commitRename();
149
+ if (e.key === 'Escape') setRenaming(false);
150
+ }}
151
+ maxLength={40}
152
+ className="min-w-0 flex-1 bg-transparent text-[13px] outline-none"
153
+ />
154
+ ) : (
155
+ <button
156
+ type="button"
157
+ onClick={onSelect}
158
+ className="min-w-0 flex-1 truncate text-left text-[13px]"
159
+ >
160
+ {label}
161
+ </button>
162
+ )}
163
+
164
+ <span className="font-mono text-[10px] text-muted-foreground tabular-nums group-hover:opacity-0">
165
+ {String(count).padStart(2, '0')}
166
+ </span>
167
+
168
+ {row.kind === 'folder' && import.meta.env.DEV && (
169
+ <div className="absolute right-1.5 opacity-0 transition-opacity group-hover:opacity-100">
170
+ <Menu
171
+ trigger={(props) => (
172
+ <button
173
+ type="button"
174
+ aria-label={`${row.folder.name} options`}
175
+ className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-background hover:text-foreground aria-expanded:bg-background"
176
+ {...props}
177
+ >
178
+ <MoreHorizontal className="size-3.5" />
179
+ </button>
180
+ )}
181
+ >
182
+ {(close) => (
183
+ <>
184
+ <MenuItem
185
+ onClick={() => {
186
+ setDraft(row.folder.name);
187
+ setRenaming(true);
188
+ close();
189
+ }}
190
+ >
191
+ <PencilLine className="size-3.5" />
192
+ Rename
193
+ </MenuItem>
194
+ <MenuSeparator />
195
+ <MenuItem
196
+ destructive
197
+ onClick={() => {
198
+ close();
199
+ row.onDelete();
200
+ }}
201
+ >
202
+ <Trash2 className="size-3.5" />
203
+ Delete folder
204
+ </MenuItem>
205
+ </>
206
+ )}
207
+ </Menu>
208
+ </div>
209
+ )}
210
+ </div>
211
+ );
212
+ }
@@ -0,0 +1,99 @@
1
+ import { useState } from 'react';
2
+ import type { FolderIcon } from '../../lib/sdk';
3
+ import { cn } from '../../lib/utils';
4
+
5
+ export const PRESET_COLORS = [
6
+ '#e5484d',
7
+ '#f76b15',
8
+ '#ffb224',
9
+ '#30a46c',
10
+ '#12a594',
11
+ '#0091ff',
12
+ '#6e56cf',
13
+ '#60646c',
14
+ ];
15
+
16
+ // A curated set beats an emoji-picker dependency for a folder chip.
17
+ const PRESET_EMOJI = [
18
+ '๐Ÿ“',
19
+ '๐Ÿ“„',
20
+ '๐Ÿ“Š',
21
+ '๐Ÿ“ˆ',
22
+ '๐Ÿงพ',
23
+ '๐Ÿ—‚๏ธ',
24
+ '๐Ÿ“Œ',
25
+ 'โญ',
26
+ '๐Ÿ›๏ธ',
27
+ '๐Ÿ’ผ',
28
+ '๐Ÿงช',
29
+ 'โš™๏ธ',
30
+ '๐Ÿš€',
31
+ '๐Ÿ”’',
32
+ '๐Ÿ’ฐ',
33
+ '๐Ÿ—“๏ธ',
34
+ ];
35
+
36
+ export function IconPicker({
37
+ value,
38
+ onChange,
39
+ }: {
40
+ value: FolderIcon;
41
+ onChange: (icon: FolderIcon) => void;
42
+ }) {
43
+ const [tab, setTab] = useState<'emoji' | 'color'>(value.type);
44
+
45
+ return (
46
+ <div className="w-[232px]">
47
+ <div className="mb-2 flex gap-1 rounded-md bg-muted p-0.5">
48
+ {(['emoji', 'color'] as const).map((next) => (
49
+ <button
50
+ key={next}
51
+ type="button"
52
+ onClick={() => setTab(next)}
53
+ className={cn(
54
+ 'flex-1 rounded px-2 py-1 text-[11px] capitalize transition-colors',
55
+ tab === next ? 'bg-background font-medium' : 'text-muted-foreground',
56
+ )}
57
+ >
58
+ {next}
59
+ </button>
60
+ ))}
61
+ </div>
62
+
63
+ {tab === 'emoji' ? (
64
+ <div className="grid grid-cols-8 gap-1">
65
+ {PRESET_EMOJI.map((emoji) => (
66
+ <button
67
+ key={emoji}
68
+ type="button"
69
+ onClick={() => onChange({ type: 'emoji', value: emoji })}
70
+ className={cn(
71
+ 'grid size-6 place-items-center rounded text-[13px] transition-transform hover:scale-110',
72
+ value.type === 'emoji' && value.value === emoji && 'bg-accent',
73
+ )}
74
+ aria-label={emoji}
75
+ >
76
+ {emoji}
77
+ </button>
78
+ ))}
79
+ </div>
80
+ ) : (
81
+ <div className="grid grid-cols-8 gap-1">
82
+ {PRESET_COLORS.map((color) => (
83
+ <button
84
+ key={color}
85
+ type="button"
86
+ onClick={() => onChange({ type: 'color', value: color })}
87
+ className={cn(
88
+ 'size-6 rounded ring-1 ring-black/10 transition-transform hover:scale-110',
89
+ value.type === 'color' && value.value === color && 'ring-2 ring-foreground',
90
+ )}
91
+ style={{ background: color }}
92
+ aria-label={color}
93
+ />
94
+ ))}
95
+ </div>
96
+ )}
97
+ </div>
98
+ );
99
+ }