@notis_ai/cli 0.2.1 → 0.2.3

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 (117) hide show
  1. package/README.md +106 -170
  2. package/config/notis_app_boundary_rules.json +50 -0
  3. package/dist/scaffolds/notis-database/app/globals.css +44 -0
  4. package/dist/scaffolds/notis-database/app/layout.tsx +6 -0
  5. package/dist/scaffolds/notis-database/app/page.tsx +1091 -0
  6. package/dist/scaffolds/notis-database/components/ui/badge.tsx +28 -0
  7. package/dist/scaffolds/notis-database/components/ui/button.tsx +53 -0
  8. package/dist/scaffolds/notis-database/components/ui/card.tsx +56 -0
  9. package/dist/scaffolds/notis-database/components/ui/table.tsx +120 -0
  10. package/dist/scaffolds/notis-database/components.json +20 -0
  11. package/dist/scaffolds/notis-database/index.html +12 -0
  12. package/dist/scaffolds/notis-database/lib/types.ts +134 -0
  13. package/dist/scaffolds/notis-database/lib/utils.ts +6 -0
  14. package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
  15. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  16. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  17. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  18. package/dist/scaffolds/notis-database/notis.config.ts +27 -0
  19. package/dist/scaffolds/notis-database/package.json +31 -0
  20. package/dist/scaffolds/notis-database/postcss.config.mjs +8 -0
  21. package/dist/scaffolds/notis-database/src/dev-main.tsx +23 -0
  22. package/dist/scaffolds/notis-database/src/mock-runtime.ts +557 -0
  23. package/dist/scaffolds/notis-database/tailwind.config.ts +59 -0
  24. package/dist/scaffolds/notis-database/tsconfig.json +23 -0
  25. package/dist/scaffolds/notis-database/vite.config.ts +22 -0
  26. package/dist/scaffolds/notis-notes/app/globals.css +3 -0
  27. package/dist/scaffolds/notis-notes/app/layout.tsx +6 -0
  28. package/dist/scaffolds/notis-notes/app/page.tsx +2168 -0
  29. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +28 -0
  30. package/dist/scaffolds/notis-notes/components/ui/button.tsx +53 -0
  31. package/dist/scaffolds/notis-notes/components/ui/card.tsx +56 -0
  32. package/dist/scaffolds/notis-notes/components.json +20 -0
  33. package/dist/scaffolds/notis-notes/lib/utils.ts +6 -0
  34. package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
  35. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  36. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  37. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  38. package/dist/scaffolds/notis-notes/notis.config.ts +36 -0
  39. package/dist/scaffolds/notis-notes/package.json +31 -0
  40. package/dist/scaffolds/notis-notes/postcss.config.mjs +8 -0
  41. package/dist/scaffolds/notis-notes/tailwind.config.ts +58 -0
  42. package/dist/scaffolds/notis-notes/tsconfig.json +23 -0
  43. package/dist/scaffolds/notis-notes/vite.config.ts +10 -0
  44. package/dist/scaffolds/notis-random/README.md +33 -0
  45. package/dist/scaffolds/notis-random/app/globals.css +11 -0
  46. package/dist/scaffolds/notis-random/app/history/page.tsx +66 -0
  47. package/dist/scaffolds/notis-random/app/layout.tsx +7 -0
  48. package/dist/scaffolds/notis-random/app/page.tsx +222 -0
  49. package/dist/scaffolds/notis-random/index.html +12 -0
  50. package/dist/scaffolds/notis-random/lib/notis-tools.ts +109 -0
  51. package/dist/scaffolds/notis-random/lib/rng.ts +42 -0
  52. package/dist/scaffolds/notis-random/lib/roll-record.ts +102 -0
  53. package/dist/scaffolds/notis-random/lib/utils.ts +25 -0
  54. package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
  55. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  56. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  57. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  58. package/dist/scaffolds/notis-random/notis.config.ts +43 -0
  59. package/dist/scaffolds/notis-random/package.json +32 -0
  60. package/dist/scaffolds/notis-random/postcss.config.mjs +6 -0
  61. package/dist/scaffolds/notis-random/src/dev-main.tsx +70 -0
  62. package/dist/scaffolds/notis-random/src/mock-runtime.ts +128 -0
  63. package/dist/scaffolds/notis-random/tailwind.config.ts +43 -0
  64. package/dist/scaffolds/notis-random/tsconfig.json +23 -0
  65. package/dist/scaffolds/notis-random/vite.config.ts +11 -0
  66. package/dist/scaffolds.json +36 -0
  67. package/package.json +8 -3
  68. package/skills/notis-apps/SKILL.md +162 -0
  69. package/skills/notis-apps/cli.md +208 -0
  70. package/skills/notis-cli/SKILL.md +260 -0
  71. package/skills/notis-query/cli.md +39 -0
  72. package/src/cli.js +31 -3
  73. package/src/command-specs/apps.js +248 -56
  74. package/src/command-specs/helpers.js +72 -104
  75. package/src/command-specs/index.js +0 -6
  76. package/src/command-specs/meta.js +14 -13
  77. package/src/command-specs/tools.js +196 -115
  78. package/src/runtime/app-boundary-validator.js +65 -14
  79. package/src/runtime/app-dev-server.js +32 -4
  80. package/src/runtime/app-platform.js +404 -24
  81. package/src/runtime/profiles.js +12 -6
  82. package/src/runtime/transport.js +124 -39
  83. package/template/.harness/index.html.tmpl +1 -50
  84. package/template/app/page.tsx +41 -6
  85. package/template/metadata/cover.png +0 -0
  86. package/template/metadata/screenshot-1.png +0 -0
  87. package/template/metadata/screenshot-2.png +0 -0
  88. package/template/metadata/screenshot-3.png +0 -0
  89. package/template/notis.config.ts +10 -6
  90. package/template/package.json +2 -2
  91. package/template/packages/{notis-sdk → sdk}/package.json +6 -0
  92. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectActionBar.tsx +2 -1
  93. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectCheckbox.tsx +2 -2
  94. package/template/packages/{notis-sdk → sdk}/src/components/MultiSelectDragOverlay.tsx +2 -2
  95. package/template/packages/{notis-sdk → sdk}/src/config.ts +1 -1
  96. package/template/packages/{notis-sdk → sdk}/src/hooks/useDatabase.ts +1 -13
  97. package/template/packages/{notis-sdk → sdk}/src/hooks/useMultiSelect.ts +4 -3
  98. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +4 -3
  99. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +3 -3
  100. package/template/packages/{notis-sdk → sdk}/src/hooks/useTool.ts +22 -7
  101. package/template/packages/{notis-sdk → sdk}/src/hooks/useUpsertDocument.ts +0 -8
  102. package/template/packages/{notis-sdk → sdk}/src/index.ts +2 -15
  103. package/template/packages/{notis-sdk → sdk}/src/provider.tsx +1 -1
  104. package/template/packages/{notis-sdk → sdk}/src/runtime.ts +5 -26
  105. package/src/command-specs/auth.js +0 -178
  106. package/src/command-specs/db.js +0 -163
  107. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  108. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  109. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  110. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  111. /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
  112. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
  113. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTopBarSearch.ts +0 -0
  114. /package/template/packages/{notis-sdk → sdk}/src/styles.css +0 -0
  115. /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
  116. /package/template/packages/{notis-sdk → sdk}/src/vite.ts +0 -0
  117. /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
@@ -0,0 +1,222 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+ import { useNotis } from '@notis/sdk';
5
+ import { DiceFiveIcon as Dices, SparkleIcon as Sparkles, ClockCounterClockwiseIcon as History, ArrowRightIcon as ArrowRight, HashIcon as Hash, PercentIcon as Percent, InfinityIcon, ArrowClockwiseIcon as RefreshCw } from '@phosphor-icons/react';
6
+ import { cn, formatNumber, relativeTime } from '@/lib/utils';
7
+ import { type Mode, type Roll, roll } from '@/lib/rng';
8
+ import { normalizeRollRecord, sortRollRecordsDesc } from '@/lib/roll-record';
9
+ import { usePersistRoll, useRollDocuments } from '@/lib/notis-tools';
10
+
11
+ const MODES: Array<{ id: Mode; label: string; icon: typeof Hash; hint: string }> = [
12
+ { id: 'integer', label: 'Integer', icon: Hash, hint: 'Whole numbers only' },
13
+ { id: 'decimal', label: 'Decimal', icon: Percent, hint: 'Continuous range' },
14
+ { id: 'dice', label: 'Dice', icon: Dices, hint: 'Classic 1–N roll' },
15
+ ];
16
+
17
+ export default function HomePage() {
18
+ const { app, ready } = useNotis();
19
+ const { documents, loading, refetch } = useRollDocuments();
20
+ const { persist: persistRoll } = usePersistRoll();
21
+
22
+ const [mode, setMode] = useState<Mode>('integer');
23
+ const [min, setMin] = useState(1);
24
+ const [max, setMax] = useState(100);
25
+ const [current, setCurrent] = useState<Roll | null>(null);
26
+ const [rolling, setRolling] = useState(false);
27
+
28
+ const recent = useMemo(
29
+ () => documents
30
+ .map(normalizeRollRecord)
31
+ .sort(sortRollRecordsDesc)
32
+ .slice(0, 5),
33
+ [documents],
34
+ );
35
+
36
+ const persist = useCallback(async (next: Roll) => {
37
+ await persistRoll(next);
38
+ refetch();
39
+ }, [persistRoll, refetch]);
40
+
41
+ const generate = useCallback(async () => {
42
+ if (rolling) return;
43
+ setRolling(true);
44
+ const next = roll(min, max, mode);
45
+ setCurrent(next);
46
+ try {
47
+ await persist(next);
48
+ } finally {
49
+ setRolling(false);
50
+ }
51
+ }, [rolling, min, max, mode, persist]);
52
+
53
+ useEffect(() => {
54
+ if (mode === 'dice') {
55
+ setMin(1);
56
+ setMax(6);
57
+ }
58
+ }, [mode]);
59
+
60
+ return (
61
+ <main className="notis-random-shell space-y-8">
62
+ <header className="flex items-start justify-between gap-6">
63
+ <div className="space-y-1.5">
64
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
65
+ <Sparkles className="h-3.5 w-3.5" />
66
+ <span>{ready ? app?.name : 'Loading…'}</span>
67
+ </div>
68
+ <h1 className="text-2xl font-semibold tracking-tight text-foreground">Generator</h1>
69
+ <p className="text-sm text-muted-foreground max-w-md">
70
+ Pick a mode, set the range, and press generate. Every roll is saved to the History view.
71
+ </p>
72
+ </div>
73
+ <button
74
+ type="button"
75
+ onClick={() => refetch()}
76
+ className="inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition"
77
+ >
78
+ <RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
79
+ Refresh
80
+ </button>
81
+ </header>
82
+
83
+ <section className="rounded-2xl border border-border bg-card p-8">
84
+ <div className="flex flex-col items-center gap-6">
85
+ <div
86
+ className={cn(
87
+ 'flex items-center justify-center rounded-full border border-border bg-background',
88
+ 'h-48 w-48 transition-transform',
89
+ rolling && 'animate-pulse',
90
+ )}
91
+ >
92
+ {current ? (
93
+ <span className="font-mono text-5xl font-semibold tabular-nums text-foreground">
94
+ {formatNumber(current.value)}
95
+ </span>
96
+ ) : (
97
+ <InfinityIcon className="h-10 w-10 text-muted-foreground" strokeWidth={1.2} />
98
+ )}
99
+ </div>
100
+ <button
101
+ type="button"
102
+ onClick={generate}
103
+ disabled={rolling}
104
+ className={cn(
105
+ 'inline-flex h-11 items-center gap-2 rounded-full px-6 text-sm font-medium',
106
+ 'bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 transition',
107
+ )}
108
+ >
109
+ <Dices className="h-4 w-4" />
110
+ {rolling ? 'Rolling…' : current ? 'Roll again' : 'Generate'}
111
+ </button>
112
+ {current && (
113
+ <p className="text-xs text-muted-foreground">
114
+ <span className="font-mono">[{formatNumber(current.min)}, {formatNumber(current.max)}]</span>
115
+ {' · '}{current.mode}
116
+ </p>
117
+ )}
118
+ </div>
119
+ </section>
120
+
121
+ <section className="grid gap-6 md:grid-cols-2">
122
+ <div className="rounded-xl border border-border bg-card p-5">
123
+ <h2 className="mb-3 text-sm font-medium text-foreground">Mode</h2>
124
+ <div className="grid gap-2">
125
+ {MODES.map(({ id, label, icon: Icon, hint }) => (
126
+ <button
127
+ key={id}
128
+ type="button"
129
+ onClick={() => setMode(id)}
130
+ className={cn(
131
+ 'flex items-center gap-3 rounded-md border px-3 py-2.5 text-left transition',
132
+ mode === id
133
+ ? 'border-foreground bg-accent text-accent-foreground'
134
+ : 'border-border hover:bg-accent/50',
135
+ )}
136
+ >
137
+ <Icon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
138
+ <div className="flex-1 min-w-0">
139
+ <div className="text-sm font-medium">{label}</div>
140
+ <div className="text-xs text-muted-foreground">{hint}</div>
141
+ </div>
142
+ </button>
143
+ ))}
144
+ </div>
145
+ </div>
146
+
147
+ <div className="rounded-xl border border-border bg-card p-5 space-y-4">
148
+ <h2 className="text-sm font-medium text-foreground">Range</h2>
149
+ <div className="grid grid-cols-2 gap-3">
150
+ <RangeInput label="Minimum" value={min} onChange={setMin} disabled={mode === 'dice'} />
151
+ <RangeInput label="Maximum" value={max} onChange={setMax} disabled={mode === 'dice'} />
152
+ </div>
153
+ {mode === 'dice' && (
154
+ <p className="text-xs text-muted-foreground">Dice mode locks to 1–6.</p>
155
+ )}
156
+ </div>
157
+ </section>
158
+
159
+ <section className="rounded-xl border border-border bg-card p-5">
160
+ <header className="mb-4 flex items-center justify-between gap-3">
161
+ <h2 className="flex items-center gap-2 text-sm font-medium text-foreground">
162
+ <History className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
163
+ Recent rolls
164
+ </h2>
165
+ <a
166
+ href="/history"
167
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
168
+ >
169
+ View all <ArrowRight className="h-3 w-3" />
170
+ </a>
171
+ </header>
172
+ {loading && recent.length === 0 ? (
173
+ <p className="text-sm text-muted-foreground">Loading…</p>
174
+ ) : recent.length === 0 ? (
175
+ <div className="rounded-md border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">
176
+ Nothing rolled yet. Press Generate to see history here.
177
+ </div>
178
+ ) : (
179
+ <ul className="divide-y divide-border">
180
+ {recent.map((r) => (
181
+ <li key={r.id} className="flex items-center justify-between py-2.5">
182
+ <div className="flex items-center gap-3">
183
+ <span className="font-mono text-lg tabular-nums text-foreground">{formatNumber(r.value)}</span>
184
+ <span className="text-xs text-muted-foreground">{r.mode}</span>
185
+ </div>
186
+ <span className="text-xs text-muted-foreground">{relativeTime(r.at)}</span>
187
+ </li>
188
+ ))}
189
+ </ul>
190
+ )}
191
+ </section>
192
+ </main>
193
+ );
194
+ }
195
+
196
+ function RangeInput({
197
+ label,
198
+ value,
199
+ onChange,
200
+ disabled,
201
+ }: {
202
+ label: string;
203
+ value: number;
204
+ onChange: (v: number) => void;
205
+ disabled?: boolean;
206
+ }) {
207
+ return (
208
+ <label className={cn('block space-y-1.5', disabled && 'opacity-50')}>
209
+ <span className="text-xs font-medium text-muted-foreground">{label}</span>
210
+ <input
211
+ type="number"
212
+ value={value}
213
+ disabled={disabled}
214
+ onChange={(e) => {
215
+ const parsed = Number(e.target.value);
216
+ if (Number.isFinite(parsed)) onChange(parsed);
217
+ }}
218
+ className="h-10 w-full rounded-md border border-border bg-background px-3 font-mono text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
219
+ />
220
+ </label>
221
+ );
222
+ }
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Random Number Generator (dev preview)</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/dev-main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,109 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import { useTool } from '@notis/sdk';
5
+ import type { RollDocument } from '@/lib/roll-record';
6
+ import type { Roll } from '@/lib/rng';
7
+ import { formatNumber } from '@/lib/utils';
8
+
9
+ type QueryRollsArgs = {
10
+ database_slug: 'rolls';
11
+ query?: {
12
+ page_size?: number;
13
+ };
14
+ offset?: number;
15
+ };
16
+
17
+ type QueryRollsResult = {
18
+ documents?: RollDocument[];
19
+ message?: string;
20
+ error?: string;
21
+ };
22
+
23
+ type UpsertRollArgs = {
24
+ title: string;
25
+ Value: number;
26
+ Mode: Roll['mode'];
27
+ Min: number;
28
+ Max: number;
29
+ 'Rolled At': string;
30
+ };
31
+
32
+ type UpsertRollResult = {
33
+ status?: string;
34
+ document?: RollDocument;
35
+ message?: string;
36
+ error?: string;
37
+ };
38
+
39
+ export function useRollDocuments(pageSize?: number) {
40
+ const queryRolls = useTool<QueryRollsArgs, QueryRollsResult>('LOCAL_NOTIS_DATABASE_QUERY');
41
+ const [documents, setDocuments] = useState<RollDocument[]>([]);
42
+ const [error, setError] = useState<Error | null>(null);
43
+ const [fetchKey, setFetchKey] = useState(0);
44
+
45
+ const refetch = useCallback(() => {
46
+ setFetchKey((key) => key + 1);
47
+ }, []);
48
+
49
+ useEffect(() => {
50
+ let cancelled = false;
51
+ setError(null);
52
+
53
+ queryRolls
54
+ .call({ database_slug: 'rolls', query: { page_size: pageSize } })
55
+ .then((result) => {
56
+ if (cancelled) return;
57
+ const message = result.error ?? result.message;
58
+ if (message && !result.documents) {
59
+ throw new Error(message);
60
+ }
61
+ setDocuments(result.documents ?? []);
62
+ })
63
+ .catch((err) => {
64
+ if (cancelled) return;
65
+ setError(err instanceof Error ? err : new Error(String(err)));
66
+ setDocuments([]);
67
+ });
68
+
69
+ return () => {
70
+ cancelled = true;
71
+ };
72
+ }, [fetchKey, pageSize, queryRolls.call]);
73
+
74
+ return {
75
+ documents,
76
+ loading: queryRolls.loading,
77
+ error: queryRolls.error ?? error,
78
+ refetch,
79
+ };
80
+ }
81
+
82
+ export function usePersistRoll() {
83
+ const upsertRoll = useTool<UpsertRollArgs, UpsertRollResult>('LOCAL_NOTIS_DATABASE_UPSERT_ROLLS');
84
+
85
+ const persist = useCallback(
86
+ async (roll: Roll) => {
87
+ const result = await upsertRoll.call({
88
+ title: formatNumber(roll.value),
89
+ Value: roll.value,
90
+ Mode: roll.mode,
91
+ Min: roll.min,
92
+ Max: roll.max,
93
+ 'Rolled At': roll.at,
94
+ });
95
+ const message = result.error ?? result.message;
96
+ if (message && result.status === 'error') {
97
+ throw new Error(message);
98
+ }
99
+ return result.document ?? null;
100
+ },
101
+ [upsertRoll.call],
102
+ );
103
+
104
+ return {
105
+ persist,
106
+ loading: upsertRoll.loading,
107
+ error: upsertRoll.error,
108
+ };
109
+ }
@@ -0,0 +1,42 @@
1
+ export type Mode = 'integer' | 'decimal' | 'dice';
2
+
3
+ export interface Roll {
4
+ value: number;
5
+ mode: Mode;
6
+ min: number;
7
+ max: number;
8
+ at: string;
9
+ }
10
+
11
+ function cryptoRandom(): number {
12
+ const array = new Uint32Array(1);
13
+ crypto.getRandomValues(array);
14
+ return (array[0] ?? 0) / 0xffffffff;
15
+ }
16
+
17
+ export function roll(min: number, max: number, mode: Mode): Roll {
18
+ const lo = Math.min(min, max);
19
+ const hi = Math.max(min, max);
20
+ let value: number;
21
+ if (mode === 'integer' || mode === 'dice') {
22
+ value = Math.floor(cryptoRandom() * (hi - lo + 1)) + lo;
23
+ } else {
24
+ value = cryptoRandom() * (hi - lo) + lo;
25
+ }
26
+ return {
27
+ value,
28
+ mode,
29
+ min: lo,
30
+ max: hi,
31
+ at: new Date().toISOString(),
32
+ };
33
+ }
34
+
35
+ export const DICE_FACES: Record<number, string> = {
36
+ 1: 'dice-1',
37
+ 2: 'dice-2',
38
+ 3: 'dice-3',
39
+ 4: 'dice-4',
40
+ 5: 'dice-5',
41
+ 6: 'dice-6',
42
+ };
@@ -0,0 +1,102 @@
1
+ import type { Mode } from '@/lib/rng';
2
+
3
+ export interface RollDocument {
4
+ id: string;
5
+ title: string;
6
+ properties?: Record<string, unknown>;
7
+ createdAt?: string | null;
8
+ created_at?: string | null;
9
+ created_time?: string | null;
10
+ lastEditedTime?: string | null;
11
+ last_edited_time?: string | null;
12
+ updated_at?: string | null;
13
+ }
14
+
15
+ function readString(value: unknown): string | null {
16
+ if (typeof value === 'string') {
17
+ const trimmed = value.trim();
18
+ return trimmed.length ? trimmed : null;
19
+ }
20
+ if (typeof value === 'number' || typeof value === 'boolean') {
21
+ return String(value);
22
+ }
23
+ if (value && typeof value === 'object') {
24
+ const record = value as Record<string, unknown>;
25
+ if (typeof record.name === 'string') {
26
+ return record.name;
27
+ }
28
+ if (typeof record.start === 'string') {
29
+ return record.start;
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+
35
+ function readNumber(value: unknown): number | null {
36
+ if (typeof value === 'number' && Number.isFinite(value)) {
37
+ return value;
38
+ }
39
+ const stringValue = readString(value);
40
+ if (!stringValue) {
41
+ return null;
42
+ }
43
+ const parsed = Number(stringValue);
44
+ return Number.isFinite(parsed) ? parsed : null;
45
+ }
46
+
47
+ function readMode(value: unknown): Mode {
48
+ const raw = readString(value)?.toLowerCase();
49
+ if (raw === 'integer' || raw === 'decimal' || raw === 'dice') {
50
+ return raw;
51
+ }
52
+ return 'integer';
53
+ }
54
+
55
+ function readTimestamp(doc: RollDocument): string {
56
+ return (
57
+ readString(doc.properties?.['Rolled At']) ||
58
+ doc.createdAt ||
59
+ doc.created_at ||
60
+ doc.created_time ||
61
+ doc.lastEditedTime ||
62
+ doc.last_edited_time ||
63
+ doc.updated_at ||
64
+ ''
65
+ );
66
+ }
67
+
68
+ function sortTimestamp(value: string): number {
69
+ if (!value) return 0;
70
+ const parsed = Date.parse(value);
71
+ return Number.isFinite(parsed) ? parsed : 0;
72
+ }
73
+
74
+ export interface RollRecord {
75
+ id: string;
76
+ value: number;
77
+ mode: Mode;
78
+ min: number | null;
79
+ max: number | null;
80
+ at: string;
81
+ }
82
+
83
+ export function normalizeRollRecord(doc: RollDocument): RollRecord {
84
+ const value =
85
+ readNumber(doc.properties?.['Value']) ??
86
+ readNumber(doc.properties?.['Name']) ??
87
+ readNumber(doc.title) ??
88
+ 0;
89
+
90
+ return {
91
+ id: doc.id,
92
+ value,
93
+ mode: readMode(doc.properties?.['Mode']),
94
+ min: readNumber(doc.properties?.['Min']),
95
+ max: readNumber(doc.properties?.['Max']),
96
+ at: readTimestamp(doc),
97
+ };
98
+ }
99
+
100
+ export function sortRollRecordsDesc(a: RollRecord, b: RollRecord): number {
101
+ return sortTimestamp(b.at) - sortTimestamp(a.at);
102
+ }
@@ -0,0 +1,25 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]): string {
5
+ return twMerge(clsx(inputs));
6
+ }
7
+
8
+ export function formatNumber(value: number): string {
9
+ if (Number.isInteger(value)) return value.toString();
10
+ return value.toFixed(4).replace(/0+$/, '').replace(/\.$/, '');
11
+ }
12
+
13
+ export function relativeTime(iso: string | null | undefined): string {
14
+ if (!iso) return '';
15
+ const then = new Date(iso).getTime();
16
+ if (Number.isNaN(then)) return '';
17
+ const diffSeconds = Math.round((Date.now() - then) / 1000);
18
+ if (diffSeconds < 5) return 'just now';
19
+ if (diffSeconds < 60) return `${diffSeconds}s ago`;
20
+ const diffMinutes = Math.round(diffSeconds / 60);
21
+ if (diffMinutes < 60) return `${diffMinutes}m ago`;
22
+ const diffHours = Math.round(diffMinutes / 60);
23
+ if (diffHours < 24) return `${diffHours}h ago`;
24
+ return new Date(iso).toLocaleDateString();
25
+ }
@@ -0,0 +1,43 @@
1
+ import { defineNotisApp } from '@notis/sdk/config';
2
+
3
+ export default defineNotisApp({
4
+ name: 'notis-random',
5
+ title: 'Random Number Generator',
6
+ description: 'Generate random numbers with configurable bounds, and keep a history of everything you rolled.',
7
+ icon: 'phosphor:dice-five',
8
+ author: { name: 'Notis' },
9
+ categories: ['Personal'],
10
+ tagline: 'Roll dice, keep history.',
11
+ versionNotes: 'Initial scaffold release.',
12
+
13
+ databases: ['rolls'],
14
+
15
+ routes: [
16
+ {
17
+ path: '/',
18
+ slug: 'home',
19
+ name: 'Generator',
20
+ icon: 'phosphor:sparkle',
21
+ default: true,
22
+ },
23
+ {
24
+ path: '/history',
25
+ slug: 'history',
26
+ name: 'History',
27
+ icon: 'phosphor:clock-counter-clockwise',
28
+ collection: {
29
+ database: 'rolls',
30
+ titleProperty: 'Value',
31
+ sidebar: {
32
+ mode: 'flat-list',
33
+ allowCreate: false,
34
+ },
35
+ },
36
+ },
37
+ ],
38
+
39
+ tools: [
40
+ 'LOCAL_NOTIS_DATABASE_QUERY',
41
+ 'LOCAL_NOTIS_DATABASE_UPSERT_ROLLS',
42
+ ],
43
+ });
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@notis-apps/notis-random",
3
+ "version": "0.1.0",
4
+ "notisAppVersion": "0.1.0",
5
+ "private": true,
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "vite build",
9
+ "dev": "vite",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@notis/sdk": "file:../../packages/sdk",
14
+ "@phosphor-icons/react": "^2.1.10",
15
+ "clsx": "^2.1.1",
16
+ "react": "^19.0.0",
17
+ "react-dom": "^19.0.0",
18
+ "tailwind-merge": "^2.6.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.0.0",
22
+ "@types/react": "^19.0.0",
23
+ "@types/react-dom": "^19.0.0",
24
+ "@vitejs/plugin-react": "^4.3.0",
25
+ "autoprefixer": "^10.4.0",
26
+ "postcss": "^8.4.0",
27
+ "tailwindcss": "^3.4.0",
28
+ "tailwindcss-animate": "^1.0.7",
29
+ "typescript": "^5.5.0",
30
+ "vite": "^6.0.0"
31
+ }
32
+ }
@@ -0,0 +1,6 @@
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
@@ -0,0 +1,70 @@
1
+ // Dev entrypoint: mounts the app pages with a tiny hash-router and the mock
2
+ // runtime. Not used in production — `vite build` uses .notis/_entry.tsx.
3
+ import { StrictMode, useEffect, useState } from 'react';
4
+ import { createRoot } from 'react-dom/client';
5
+ import { NotisProvider } from '@notis/sdk';
6
+ import AppShell from '../app/layout';
7
+ import HomePage from '../app/page';
8
+ import HistoryPage from '../app/history/page';
9
+ import { installMockRuntime, setMockRoute } from './mock-runtime';
10
+
11
+ const runtime = installMockRuntime(hashRoute());
12
+
13
+ function hashRoute(): 'home' | 'history' {
14
+ return window.location.hash === '#/history' ? 'history' : 'home';
15
+ }
16
+
17
+ function DevApp() {
18
+ const [route, setRoute] = useState<'home' | 'history'>(hashRoute());
19
+
20
+ useEffect(() => {
21
+ const onChange = () => {
22
+ const next = hashRoute();
23
+ setRoute(next);
24
+ setMockRoute(next);
25
+ };
26
+ window.addEventListener('hashchange', onChange);
27
+ return () => window.removeEventListener('hashchange', onChange);
28
+ }, []);
29
+
30
+ const Page = route === 'history' ? HistoryPage : HomePage;
31
+
32
+ return (
33
+ <AppShell>
34
+ <div className="mx-auto max-w-4xl px-4 pt-4">
35
+ <nav className="flex items-center gap-2 rounded-full border border-border bg-card p-1 text-sm w-fit">
36
+ <a
37
+ href="#/"
38
+ className={
39
+ (route === 'home' ? 'bg-accent text-accent-foreground ' : 'text-muted-foreground hover:text-foreground ') +
40
+ 'rounded-full px-3 py-1 transition'
41
+ }
42
+ >
43
+ Generator
44
+ </a>
45
+ <a
46
+ href="#/history"
47
+ className={
48
+ (route === 'history' ? 'bg-accent text-accent-foreground ' : 'text-muted-foreground hover:text-foreground ') +
49
+ 'rounded-full px-3 py-1 transition'
50
+ }
51
+ >
52
+ History
53
+ </a>
54
+ <span className="pl-2 pr-3 text-[10px] uppercase tracking-wide text-muted-foreground">dev preview</span>
55
+ </nav>
56
+ </div>
57
+ <Page />
58
+ </AppShell>
59
+ );
60
+ }
61
+
62
+ const root = document.getElementById('root');
63
+ if (!root) throw new Error('#root element missing from index.html');
64
+ createRoot(root).render(
65
+ <StrictMode>
66
+ <NotisProvider runtime={runtime}>
67
+ <DevApp />
68
+ </NotisProvider>
69
+ </StrictMode>,
70
+ );