Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 dfbf9be61c the two messages are an obligation in the brief, not advice in the skill
A commander read the skill section on telling the owner and did not mention it in
its read-back, so the owner would have had to remind it — which is the failure, not
the remedy. Doctrine in the skill is advice a commander may or may not weigh;
the brief is the prompt and always arrives. Same lesson as moving the output
constraints there earlier tonight.

So the brief now states it as owed: two messages per event, one when the thing
happens and one when the work is done, and never having to be asked. The skill
still carries the how.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:18:32 +00:00
pastilhas be29ae7aa7 delete two unreferenced components from another codebase
MarkdownEditor and ui/start-confetti-button had no importers anywhere in the repo
and both carried a different product's content: MarkdownEditor hardcoded
https://app.pertento.ai/logo.png with alt="Pertento", and start-confetti-button
spoke about 'the experiment' and 'Draft/Created' statuses.

Translating either would have put feature words into ui.*, which D6 forbids —
a shared namespace may not name a feature.

todo/inputs-outside-forms named MarkdownEditor as deliberately-unwrapped, so that
entry is updated rather than left pointing at a deleted file.
2026-08-23 04:18:08 +00:00
pastilhas e3e790fd2b command block and colour picker keyed
ColorPicker's one string was placeholder="Font Weight" on an <input type="color">,
where a placeholder is both wrong text and inert — browsers do not render one on a
colour input. Keyed it as ui.colorPicker.placeholder and moved it to the hex text
field beside it, which is a real text input and had none. Translating it in place
would have created a key guaranteed never to be seen.

Both files still have no importers; CommandBlock stays because commands the user
might run need a copy button by convention.
2026-08-23 04:16:54 +00:00
pastilhas 2b0b671895 scanner: separate never-translate hits from convertible ones, and stop counting tailwind as prose
The --check guard compared an area's whole hit count against zero, including
'thrown errors' — which the doc's never-translate list forbids converting. An
area holding one legitimate new Error() could therefore never be added to
ZEROED, so the debt guard could never be switched on for it at all. Categories
now carry neverTranslate, are still reported, and are excluded from --check.

Also drops sonner's 'group-[.toast]:text-muted-foreground', matched as an object
label. The filter tests per token rather than per string: the first attempt
tested the whole string and ate a real label, 'Application type:'.

--check now prints the offending strings and files instead of only a count.
2026-08-23 04:13:21 +00:00
pastilhas 2ec3f11cdd shadcn primitives and logos keyed: dialog, sheet, pagination, carousel, breadcrumb, theme toggles
These are the strings a user meets everywhere, mostly sr-only: the dialog and
sheet close buttons, pagination labels, carousel arrows, the breadcrumb ellipsis
and both colour-mode toggles. nav aria-label='pagination' became a real key too —
a screen reader announces it, so it is user-facing.

Logo alt text keeps the product name and translates only around it
('Logótipo Officer'). Arrow-button insets became logical (-start-12/-end-12).

The three 'useX must be used within <Y>' Errors are deliberately untouched:
never-translate, per the doc's list.
2026-08-23 04:10:39 +00:00
pastilhas 5ce1a2928e inputs and data views keyed: search, select, tabs, state blocks, data table
The DataTable's pagination carries the only interpolated strings in this area
('Showing page {{current}} of {{total}}', '{{size}} / page'). No plural forms:
nothing here is a count-varying noun, so inventing one would only add a key
nobody uses.

DataTable.test.tsx now renders inside an I18nProvider with the real en-GB
catalog, so it still asserts the English a user sees rather than a dot-path.

Left/right utilities became logical (ms-/me-/ps-/pe-/start-) per D3.
2026-08-23 04:10:33 +00:00
pastilhas 339d31f05c ui.* catalog and the dialogs: error dialogs, confirm and paper dialogs keyed
Establishes the ui.* namespace for phases 2 and 3. Both catalogs complete, so the
key-parity test holds. Two English copy defects fixed while keying, because the
Portuguese must not inherit them: 'Your are not authorized' -> 'You are', and
'Please Signin again' -> 'Please sign in again'.

The server's own message in ServerError/CustomError stays verbatim — it is a
response, not UI copy (D1).
2026-08-23 04:10:27 +00:00
pastilhas a444280cb4 deploy report 2026.08.23.03 2026-08-23 04:02:58 +00:00
32 changed files with 522 additions and 519 deletions
+42
View File
@@ -0,0 +1,42 @@
# 2026.08.23.03 — deploy report
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the rollout ran from it)
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`, down by the owner's call — not
attempted, nothing spent on it).
Every host fast-forwarded to the tag `deploy/2026.08.23.03` (`c0788d15`) from `0a8fe1c2`. No host was
dirty in tracked files. No `bun install` was run, per the plan. No direct SQL was run — none was
authorised and none was needed.
**Schema check, per host, by query — not by exit code.** `select name from roles order by name` returned
the same four rows everywhere: `Admin, Developer, Member, Super Admin`. `bun db:push` also printed
`[seed:roles] 4 seeded, 0 already present` on all five, so the `postdb:push` hook ran.
## Per host
| Host | HEAD before → after | db:push | `roles` query | plugins:update | restarted | back up |
| --- | --- | --- | --- | --- | --- | --- |
| `alpha` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 12/12 in 49s | `officer` 68→69, + 12 plugin sidecars | online |
| `fofos` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 7s | `officer` 22→23, + `officer-gitea` | online |
| `production-pertento-bridge` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 4s | `officer` 1→2, + `officer-gitea` | online |
| `deadpool-rezio-bridge` (`br`) | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 2/2 in 7s | `officer` 7→8, + `officer-email`, `officer-offscale` | online |
| `macbookpro` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | no plugins installed | `officer` 9→10 | online |
| `officer-dev` | — | — | 4 rows ✓ (pre-existing) | — | **skipped: this rollout ran on it** | — |
| `edge-pertento-bridge` | — | — | — | — | **skipped: down, per `docs/servers.md`** | — |
A final `pm2 jlist` sweep across the five deployed hosts found **no process in any state other than
`online`** — not just `officer`, every entry.
## Notes, and what was not done
- **`officer-dev` still needs its own restart.** It already holds `c0788d15` and the four rows, but
nothing on it was pulled or bounced: the deploy agent runs inside `officer-claude-code` there, and
restarting `officer` on it is the owner's deliberate last step.
- **`fofos` carries one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could
never have collided with it, so it is not a reason to skip a host.
- **`plugins:update` restarts more than the plan's `pm2 restart officer`.** That is the script's own
behaviour and it is in the "restarted" column above rather than hidden: 12 sidecars bounced on `alpha`
before `officer` itself was touched.
- **`macbookpro` was awake** and needed the documented PATH append (`/opt/homebrew/bin`) for `bun`, with
`pm2` run separately through `$SHELL -ic`. It has no plugins installed, so step 3 was a no-op there.
- **Nothing was retried and nothing was repaired.** No step failed on any host.
+51 -8
View File
@@ -34,21 +34,36 @@ const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\
// A quoted literal, single or double. Kept as two groups because JS has no branch reset.
const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
const PATTERNS: { kind: string; rx: RegExp }[] = [
/**
* `neverTranslate` marks a category the doc's "What must never be translated" list forbids converting —
* so it is reported (an agent should see it and confirm), but excluded from `--check`.
*
* Without that split the guard is unusable: an area holding one legitimate `new Error()` can never
* reach zero, so it could never be added to ZEROED, so the debt guard could never be switched on for
* it. Counting work that must NOT be done against a target of zero makes the target unreachable.
*/
const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
// >Some visible words<
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
// placeholder="..." and friends
{
kind: 'attributes',
rx: new RegExp(String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q, 'g'),
rx: new RegExp(
String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q,
'g',
),
},
{ kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') },
// label: 'Status' — nav items, menus, table columns. The largest hidden category.
{
kind: 'object labels',
rx: new RegExp(String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q, 'g'),
rx: new RegExp(
String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q,
'g',
),
},
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g') },
// Thrown Errors are for whoever is reading a terminal at 4am. They stay in one language.
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g'), neverTranslate: true },
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
];
@@ -62,6 +77,16 @@ function isHuman(value: string | undefined): value is string {
if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
if (/^[A-Z0-9_]+$/.test(s)) return false;
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
// `description: 'group-[.toast]:text-muted-foreground'`.
//
// The test is per TOKEN, not on the whole string: every space-separated token must look like a
// utility (all lowercase, no sentence punctuation) and at least one must carry a variant colon or
// an arbitrary-value bracket. Testing the whole string instead cost a real label — "Application
// type:" was filtered because it ends in a colon, which is also how prose introduces a value.
const tokens = s.split(/\s+/);
const utilityish = (tok: string) => /^[a-z0-9:_\-[\]().#&/%!]+$/.test(tok);
if (tokens.every(utilityish) && tokens.some((tok) => /[:[]/.test(tok))) return false;
return true;
}
@@ -118,7 +143,9 @@ const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
const kinds = PATTERNS.map((p) => p.kind);
const pad = (s: string | number, n: number) => String(s).padStart(n);
console.log(`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`);
console.log(
`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`,
);
for (const r of results) {
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join('');
console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
@@ -128,9 +155,19 @@ const all = results.flatMap((r) => r.hits);
const distinct = new Set(all.map((h) => h.value));
const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value));
console.log(`${'TOTAL'.padEnd(22)} ${pad(results.reduce((n, r) => n + r.files, 0), 5)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`);
const NEVER_KINDS = new Set(PATTERNS.filter((p) => p.neverTranslate).map((p) => p.kind));
const convertible = (hits: Hit[]) => hits.filter((h) => !NEVER_KINDS.has(h.kind));
console.log(
`${'TOTAL'.padEnd(22)} ${pad(
results.reduce((n, r) => n + r.files, 0),
5,
)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`,
);
console.log();
console.log(`occurrences ${all.length}`);
console.log(` convertible ${convertible(all).length}`);
console.log(` never-translate ${all.length - convertible(all).length} (${[...NEVER_KINDS].join(', ')})`);
console.log(`distinct strings ${distinct.size}`);
console.log(` needing params ${interpolated.size}`);
@@ -149,10 +186,16 @@ if (listPrefix) {
}
if (args.has('--check')) {
const regressed = results.filter((r) => ZEROED.includes(r.label) && r.hits.length > 0);
const regressed = results
.filter((r) => ZEROED.includes(r.label))
.map((r) => ({ label: r.label, hits: convertible(r.hits) }))
.filter((r) => r.hits.length > 0);
if (regressed.length > 0) {
console.error('\nThese areas were converted and have regressed:');
for (const r of regressed) console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
for (const r of regressed) {
console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
for (const h of r.hits) console.error(` [${h.kind}] ${h.value} — ${h.file}`);
}
console.error('\nUse a catalog key. See docs/localization.md.');
process.exit(1);
}
+7 -3
View File
@@ -1,11 +1,15 @@
import { Input } from '@/components/ui/input';
import { useTranslation } from '@/components/locales';
type ColorPickerProps = { value?: string; name: string; label?: string };
export const ColorPicker = (props: ColorPickerProps) => {
const { value = '#000000', name, label } = props;
const { t } = useTranslation();
return (
<div style={{ borderWidth: '1px' }} className="relative w-full rounded">
{label && <span className="text-muted-foreground absolute z-10 -mt-2 ml-5 bg-background px-2 text-xs">{label}</span>}
{label && (
<span className="text-muted-foreground absolute z-10 -mt-2 ms-5 bg-background px-2 text-xs">{label}</span>
)}
<div className="flex items-center justify-between">
<div className="h-12 overflow-hidden rounded-full p-0">
<Input
@@ -13,14 +17,14 @@ export const ColorPicker = (props: ColorPickerProps) => {
className="absolute h-12 w-24 rounded-full border-0 text-lg"
type="color"
name={name}
placeholder="Font Weight"
/>
</div>
<div>
<Input
name={name}
className="border-0 text-right text-base outline-none focus:outline-none"
className="border-0 text-end text-base outline-none focus:outline-none"
defaultValue={value}
placeholder={t('colorPicker.placeholder')}
/>
</div>
</div>
+5 -3
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Copy, Check, Play } from 'lucide-react';
import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '@/components/locales';
type CommandBlockProps = {
label?: string;
@@ -9,6 +10,7 @@ type CommandBlockProps = {
};
export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copy = () => {
@@ -20,14 +22,14 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
return (
<div className="inline-flex flex-col gap-1">
{label && <span className="text-xs text-duck-dark/50">{label}</span>}
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg pl-3 pr-1 py-1.5">
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg ps-3 pe-1 py-1.5">
<code className="text-sm text-[#e0e0e0] font-mono whitespace-nowrap">{command}</code>
{onRun && (
<button
type="button"
onClick={() => onRun(command)}
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
title="Run in terminal"
title={t('commandBlock.run')}
>
<Play className="h-3.5 w-3.5 text-duck-teal" />
</button>
@@ -36,7 +38,7 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
type="button"
onClick={copy}
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
title="Copy command"
title={t('commandBlock.copy')}
>
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5 text-white/40" />}
</button>
+10 -6
View File
@@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { Loader2, TriangleAlert } from 'lucide-react';
import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
// Loading, failed, empty. See docs/design-language-interface.md § 6.
//
@@ -19,12 +20,15 @@ const Shell = ({ children, className }: { children: ReactNode; className?: strin
</div>
);
export const LoadingBlock = ({ label = 'Loading…' }: { label?: string }) => (
<Shell>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{label}</p>
</Shell>
);
export const LoadingBlock = ({ label }: { label?: string }) => {
const { t } = useTranslation();
return (
<Shell>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{label ?? t('state.loading')}</p>
</Shell>
);
};
/**
* The real message is shown, not a friendly substitute for it. On a self-hosted platform the person
@@ -1,7 +1,21 @@
import type { Column } from './DataTable';
import type { ReactNode } from 'react';
import { describe, expect, test } from 'bun:test';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { I18nProvider } from 'i18n';
import { DataTable, useDataControl } from './DataTable';
import enGB from '../locales/en-GB.json';
/**
* The table's own chrome ("Next", "No Data Found.") comes from the `ui.*` catalog now, so these
* assertions only hold inside a provider — the same one the app mounts. Asserting the English a user
* actually sees, rather than a key, is the point of wrapping rather than loosening the expectations.
*/
const withI18n = (ui: ReactNode) => (
<I18nProvider translations={{ 'en-GB': { ui: enGB } }} locale="en-GB" fallbackLocale="en-GB">
{ui}
</I18nProvider>
);
type TestItem = {
id: number;
@@ -55,7 +69,7 @@ const TestWrapper = ({
describe('DataTable', () => {
describe('rendering', () => {
test('renders table with data', () => {
render(<TestWrapper />);
render(withI18n(<TestWrapper />));
expect(screen.getByRole('table')).toBeDefined();
expect(screen.getByText('Alpha')).toBeDefined();
@@ -63,7 +77,7 @@ describe('DataTable', () => {
});
test('renders column headers', () => {
render(<TestWrapper />);
render(withI18n(<TestWrapper />));
// Component renders both desktop and mobile versions (hidden via CSS)
// so we use getAllByText and verify at least one exists
@@ -74,7 +88,7 @@ describe('DataTable', () => {
});
test('renders correct number of rows based on page size', () => {
render(<TestWrapper pageSize={3} />);
render(withI18n(<TestWrapper pageSize={3} />));
const rows = screen.getAllByRole('row');
// 1 header row + 3 data rows
@@ -82,20 +96,20 @@ describe('DataTable', () => {
});
test('shows no data message when data is empty', () => {
render(<TestWrapper data={[]} noDataMessage="No items found" />);
render(withI18n(<TestWrapper data={[]} noDataMessage="No items found" />));
expect(screen.getByText('No items found')).toBeDefined();
expect(screen.queryByRole('table')).toBeNull();
});
test('shows default no data message', () => {
render(<TestWrapper data={[]} />);
render(withI18n(<TestWrapper data={[]} />));
expect(screen.getByText('No Data Found.')).toBeDefined();
});
test('renders noDataCTA when provided', () => {
render(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />);
render(withI18n(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />));
expect(screen.getByRole('button', { name: 'Add Item' })).toBeDefined();
});
@@ -106,7 +120,7 @@ describe('DataTable', () => {
return <DataTable dataController={controller} columns={columns} />;
};
const { container } = render(<NullWrapper />);
const { container } = render(withI18n(<NullWrapper />));
expect(container.innerHTML).toBe('');
});
});
@@ -118,7 +132,7 @@ describe('DataTable', () => {
return <DataTable dataController={controller} />;
};
render(<AutoColumnsWrapper />);
render(withI18n(<AutoColumnsWrapper />));
// Should generate columns from object keys (rendered for both mobile/desktop)
expect(screen.getAllByText('Id').length).toBeGreaterThan(0);
@@ -130,7 +144,7 @@ describe('DataTable', () => {
describe('sorting', () => {
test('clicking sortable column header triggers sort', () => {
render(<TestWrapper pageSize={10} />);
render(withI18n(<TestWrapper pageSize={10} />));
// Get the first "Name" element (desktop version)
const nameHeaders = screen.getAllByText('Name');
@@ -143,7 +157,7 @@ describe('DataTable', () => {
});
test('clicking same column twice reverses sort order', () => {
render(<TestWrapper pageSize={10} />);
render(withI18n(<TestWrapper pageSize={10} />));
const nameHeaders = screen.getAllByText('Name');
fireEvent.click(nameHeaders[0]!); // asc
@@ -156,7 +170,7 @@ describe('DataTable', () => {
});
test('sort icon is displayed for sortable columns', () => {
render(<TestWrapper />);
render(withI18n(<TestWrapper />));
// Each sortable column should have the ArrowUpDown icon
const headers = screen.getAllByRole('columnheader');
@@ -169,19 +183,19 @@ describe('DataTable', () => {
describe('pagination', () => {
test('shows pagination bar when data exceeds page size', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined();
});
test('does not show pagination when all data fits on one page', () => {
render(<TestWrapper pageSize={10} />);
render(withI18n(<TestWrapper pageSize={10} />));
expect(screen.queryByText(/Showing page/)).toBeNull();
});
test('next button navigates to next page', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton);
@@ -192,7 +206,7 @@ describe('DataTable', () => {
});
test('previous button navigates to previous page', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton);
@@ -205,7 +219,7 @@ describe('DataTable', () => {
});
test('first button goes to first page', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton);
@@ -217,7 +231,7 @@ describe('DataTable', () => {
});
test('last button goes to last page', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const lastButton = screen.getByRole('button', { name: 'Last' });
fireEvent.click(lastButton);
@@ -226,7 +240,7 @@ describe('DataTable', () => {
});
test('navigation buttons are disabled at boundaries', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const firstBtn = screen.getByRole('button', { name: 'First' });
const prevBtn = screen.getByRole('button', { name: 'Prev' });
@@ -258,7 +272,7 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: false },
];
render(<TestWrapper cols={conditionalColumns} />);
render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('ID').length).toBeGreaterThan(0);
expect(screen.getAllByText('Name').length).toBeGreaterThan(0);
@@ -271,7 +285,7 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: true },
];
render(<TestWrapper cols={conditionalColumns} />);
render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
});
@@ -283,11 +297,11 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: () => showStatus },
];
const { rerender } = render(<TestWrapper cols={conditionalColumns} />);
const { rerender } = render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.queryByText('Status')).toBeNull();
showStatus = true;
rerender(<TestWrapper cols={conditionalColumns} />);
rerender(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
});
});
@@ -303,7 +317,7 @@ describe('DataTable', () => {
},
];
render(<TestWrapper cols={formattedColumns} />);
render(withI18n(<TestWrapper cols={formattedColumns} />));
const formattedNames = screen.getAllByTestId('formatted-name');
expect(formattedNames.length).toBeGreaterThan(0);
@@ -319,7 +333,7 @@ describe('DataTable', () => {
},
];
render(<TestWrapper cols={formattedColumns} />);
render(withI18n(<TestWrapper cols={formattedColumns} />));
// Each row should have a unique test ID based on item.id
expect(screen.getByTestId('item-1')).toBeDefined();
@@ -337,7 +351,7 @@ describe('DataTable', () => {
},
];
render(<TestWrapper cols={actionColumns} />);
render(withI18n(<TestWrapper cols={actionColumns} />));
expect(screen.getByTestId('action-1')).toBeDefined();
expect(screen.getByText('Edit Alpha')).toBeDefined();
@@ -351,7 +365,7 @@ describe('DataTable', () => {
{ field: 'name', label: 'Name' },
];
render(<TestWrapper cols={tooltipColumns} />);
render(withI18n(<TestWrapper cols={tooltipColumns} />));
// The tooltip indicator (ⓘ) should be present in the ID column headers
const idHeaders = screen.getAllByText(/ID/);
@@ -367,7 +381,7 @@ describe('DataTable', () => {
{ field: 'name', label: 'Full Name', labelMobile: 'Name' },
];
render(<TestWrapper cols={mobileColumns} />);
render(withI18n(<TestWrapper cols={mobileColumns} />));
// Both desktop and mobile labels should be in the DOM (hidden/shown via CSS)
expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0);
@@ -396,7 +410,7 @@ describe('DataTable', () => {
);
};
render(<SearchableTable />);
render(withI18n(<SearchableTable />));
const searchInput = screen.getByTestId('search-input');
fireEvent.change(searchInput, { target: { value: 'Alpha' } });
@@ -420,7 +434,7 @@ describe('DataTable', () => {
);
};
render(<ExternalSortTable />);
render(withI18n(<ExternalSortTable />));
fireEvent.click(screen.getByTestId('sort-btn'));
@@ -6,8 +6,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { useDataControl, type UseDataControlReturn } from 'hooks/useDataControl';
import { PaginationBar } from './PaginationBar';
import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const DataTable = <T,>(props: DataTableProps<T>) => {
const { t } = useTranslation();
const [columns, setColumns] = useState<Column<T>[]>([]);
const { dataController, uniqueKey, noDataCTA, noDataMessage } = props;
const theUniqueKey = uniqueKey || 'id';
@@ -58,7 +60,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
if (rawData.length === 0) {
return (
<div className="py-12 text-center">
<div className="text-muted-foreground">{noDataMessage || 'No Data Found.'}</div>
<div className="text-muted-foreground">{noDataMessage || t('dataTable.empty')}</div>
{noDataCTA && <>{noDataCTA}</>}
</div>
);
@@ -94,7 +96,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
<span className="min-[500px]:hidden">
{String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''}
</span>
{sortKey && <ArrowUpDown className={cn('ml-2 h-4 w-4')} />}
{sortKey && <ArrowUpDown className={cn('ms-2 h-4 w-4')} />}
</div>
</TooltipTrigger>
{tooltip && (
@@ -3,12 +3,14 @@ import type { UseDataControlReturn } from 'hooks/useDataControl';
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTranslation } from '@/components/locales';
type PaginationBarProps<T> = {
dataManager: UseDataControlReturn<T>;
};
export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
const { t } = useTranslation();
const { currentPage, pageCount, changePage, pageSize, setPageSize } = props.dataManager;
const pageSizeOptions = useMemo(() => [5, 10, 15, 20], []);
@@ -28,10 +30,10 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
{/* LEFT */}
<div className="flex items-center gap-2 min-[500px]:gap-3 text-sm text-muted-foreground whitespace-nowrap">
<span className="hidden min-[500px]:inline">
Showing page {currentPage} of {pageCount}
{t('dataTable.pagination.showingPage', { current: currentPage, total: pageCount })}
</span>
<span className="min-[500px]:hidden">
p {currentPage}/{pageCount}
{t('dataTable.pagination.showingPageShort', { current: currentPage, total: pageCount })}
</span>
<span className="bg-border h-5 w-px" aria-hidden="true" />
@@ -47,11 +49,11 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
hover:bg-background
data-[state=open]:bg-muted
"
aria-label="Rows per page"
aria-label={t('dataTable.pagination.rowsPerPage')}
>
<SelectValue className="text-muted-foreground">
<span className="hidden min-[500px]:inline">{pageSize} / page</span>
<span className="min-[500px]:hidden">{pageSize}/p</span>
<span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: pageSize })}</span>
<span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: pageSize })}</span>
</SelectValue>
</SelectTrigger>
@@ -66,8 +68,8 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
data-[state=checked]:text-muted-foreground
"
>
<span className="hidden min-[500px]:inline">{n} / page</span>
<span className="min-[500px]:hidden">{n}/p</span>
<span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: n })}</span>
<span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: n })}</span>
</SelectItem>
))}
</SelectContent>
@@ -82,7 +84,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass}
onClick={() => changePage(1)}
disabled={currentPage === 1}
aria-label="First"
aria-label={t('dataTable.pagination.first')}
>
«
</Button>
@@ -92,7 +94,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass}
onClick={() => changePage(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Prev"
aria-label={t('dataTable.pagination.prev')}
>
‹
</Button>
@@ -102,7 +104,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass}
onClick={() => changePage(currentPage + 1)}
disabled={currentPage === pageCount}
aria-label="Next"
aria-label={t('dataTable.pagination.next')}
>
›
</Button>
@@ -112,7 +114,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass}
onClick={() => changePage(pageCount)}
disabled={currentPage === pageCount}
aria-label="Last"
aria-label={t('dataTable.pagination.last')}
>
»
</Button>
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
type ConfirmDialogProps = {
open: boolean;
@@ -16,8 +17,9 @@ type ConfirmDialogProps = {
export const ConfirmDialog = (props: ConfirmDialogProps) => {
const [confirming, setConfirming] = useState(false);
const { t } = useTranslation();
const { open, disabled = false, title, description, children } = props;
const { onConfirm, onCancel, confirmLabel = 'Confirm', confirmVariant = 'default' } = props;
const { onConfirm, onCancel, confirmLabel, confirmVariant = 'default' } = props;
const handleConfirm = async () => {
setConfirming(true);
@@ -37,16 +39,16 @@ export const ConfirmDialog = (props: ConfirmDialogProps) => {
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
{t('button.cancel')}
</Button>
<Button
type="button"
variant={confirmVariant}
onClick={handleConfirm}
disabled={disabled || confirming}
className="sm:ml-2"
className="sm:ms-2"
>
{confirmLabel}
{confirmLabel ?? t('button.confirm')}
</Button>
</div>
</DialogContent>
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card';
import { useTranslation } from '@/components/locales';
type PaperDialogProps = {
open: boolean;
@@ -11,6 +12,8 @@ type PaperDialogProps = {
};
export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: PaperDialogProps) => {
const { t } = useTranslation();
const handleOpenAutoFocus = (ev: Event) => {
ev.preventDefault();
onOpenAutoFocus?.(ev);
@@ -25,9 +28,9 @@ export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: P
className="fixed left-[50%] top-[50%] z-[600] grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border-2 border-duck-dark/30 text-duck-dark p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg"
style={cardStyle()}
>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<span className="h-4 w-4 text-duck-dark">✕</span>
<span className="sr-only">Close</span>
<span className="sr-only">{t('button.close')}</span>
</DialogPrimitive.Close>
{children}
</DialogPrimitive.Content>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index';
export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => {
if (!value) {
setApiError(null);
@@ -16,14 +18,15 @@ export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
<DialogHeader>
<DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" />
Server Error
{t('error.server.title')}
</DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
</DialogHeader>
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
<p>{apiError?.message}</p>
<DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}>
Ok
{t('button.ok')}
</Button>
</DialogFooter>
</DialogContent>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index';
export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => {
if (!value) {
setApiError(null);
@@ -16,16 +18,14 @@ export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
<DialogHeader>
<DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" />
Unauthorized Access
{t('error.forbidden.title')}
</DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">
Your are not authorized to perform this action.
</DialogDescription>
<DialogDescription className="flex flex-col gap-4 py-5">{t('error.forbidden.description')}</DialogDescription>
</DialogHeader>
<p>If you think this is an error, please contact your manager.</p>
<p>{t('error.forbidden.hint')}</p>
<DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}>
Ok
{t('button.ok')}
</Button>
</DialogFooter>
</DialogContent>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index';
export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => {
if (!value) {
setApiError(null);
@@ -16,14 +18,15 @@ export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
<DialogHeader>
<DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" />
Server Error
{t('error.server.title')}
</DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
</DialogHeader>
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
<p>{apiError?.message}</p>
<DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}>
Ok
{t('button.ok')}
</Button>
</DialogFooter>
</DialogContent>
@@ -3,10 +3,12 @@ import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index';
export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
const { signout } = useAuth();
const { t } = useTranslation();
const onOpenChange = (value: boolean) => {
if (!value) {
setApiError(null);
@@ -20,14 +22,16 @@ export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
<DialogHeader>
<DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" />
Invalid Credentials
{t('error.unauthorized.title')}
</DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">Your access token has expired</DialogDescription>
<DialogDescription className="flex flex-col gap-4 py-5">
{t('error.unauthorized.description')}
</DialogDescription>
</DialogHeader>
<p>Please Signin again</p>
<p>{t('error.unauthorized.hint')}</p>
<DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}>
Ok
{t('button.ok')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,17 +1,14 @@
import { useTranslation } from '@/components/locales';
type SvgLogoProps = {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
};
export function OfficerIconLogo({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig[size];
return (
<img
src="/officer-logo.svg"
alt="Officer Icon"
style={{ width: config.width, height: 'auto' }}
/>
);
return <img src="/officer-logo.svg" alt={t('logo.icon')} style={{ width: config.width, height: 'auto' }} />;
}
const sizeConfig = {
@@ -1,3 +1,5 @@
import { useTranslation } from '@/components/locales';
type SvgLogoProps = {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
square?: boolean;
@@ -12,27 +14,17 @@ export function OfficerSvgLogo({ size = 'xl', square = false }: SvgLogoProps) {
}
export function OfficerSvgLogoRegular({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig.regular[size];
return (
<img
src="/officer-logo.svg"
alt="Officer Logo"
style={{ width: config.width, height: 'auto' }}
/>
);
return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
}
export function OfficerSvgLogoSquare({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig.square[size];
return (
<img
src="/officer-logo.svg"
alt="Officer Logo"
style={{ width: config.width, height: 'auto' }}
/>
);
return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
}
const sizeConfig = {
@@ -1,115 +0,0 @@
import { useState } from 'react';
import md from 'markdown-it';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { Button } from '@/components/ui/button';
import { Code, Eye } from 'lucide-react';
type MarkdownEditorProps = {
content: string;
onChange: (content: string) => void;
placeholder?: string;
greeting?: string;
firstName?: string;
lastName?: string;
};
export function MarkdownEditor({
content,
onChange,
placeholder = 'Enter markdown...',
greeting,
firstName,
lastName,
}: MarkdownEditorProps) {
const [showPreview, setShowPreview] = useState(false);
const markdown = md({
html: true,
breaks: true,
linkify: true,
});
const processGreeting = (text: string) => {
return text
.replace(/\{\{firstName\}\}/g, firstName || '')
.replace(/\{\{fullName\}\}/g, `${firstName || ''} ${lastName || ''}`.trim());
};
const processedGreeting = greeting ? processGreeting(greeting) : '';
const greetingHtml = processedGreeting ? `<p>${processedGreeting}</p>` : '';
const preview = markdown.render(content);
return (
<div className="flex flex-col rounded-lg border border-border bg-background overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-2 border-b border-border bg-muted px-4 py-2">
<Button size="sm" variant={!showPreview ? 'default' : 'outline'} onClick={() => setShowPreview(false)}>
<Code className="h-4 w-4 mr-2" />
Markdown
</Button>
<Button size="sm" variant={showPreview ? 'default' : 'outline'} onClick={() => setShowPreview(true)}>
<Eye className="h-4 w-4 mr-2" />
Preview
</Button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-hidden">
{!showPreview ? (
<textarea
rows={15}
name="content"
value={content}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full h-full resize-none p-4 font-mono text-sm focus:outline-none bg-background text-foreground placeholder:text-muted-foreground overflow-auto"
/>
) : (
<div className="w-full h-full overflow-auto bg-slate-50 dark:bg-slate-900 p-4">
{/* Email wrapper simulation */}
<div className="mx-auto max-w-2xl rounded-lg bg-background p-8 shadow-lg">
{/* Logo */}
<div className="mb-8 text-center">
<img src="https://app.pertento.ai/logo.png" alt="Pertento" className="h-16 mx-auto" />
</div>
{/* Content */}
<div className="space-y-4">
{processedGreeting && <p className="text-base leading-relaxed">{processedGreeting}</p>}
<style>{`
.markdown-preview h1 { font-size: 2rem; font-weight: bold; margin: 1.5rem 0 1rem 0; }
.markdown-preview h2 { font-size: 1.5rem; font-weight: bold; margin: 1.25rem 0 0.75rem 0; }
.markdown-preview h3 { font-size: 1.25rem; font-weight: bold; margin: 1rem 0 0.5rem 0; }
.markdown-preview p { margin: 0.75rem 0; line-height: 1.6; }
/* list-style is restored explicitly — tailwind preflight strips it, which leaves an
ordered list indented but unnumbered. */
.markdown-preview ul, .markdown-preview ol { margin: 1rem 0; padding-left: 2rem; list-style-position: outside; }
.markdown-preview ul { list-style-type: disc; }
.markdown-preview ol { list-style-type: decimal; }
.markdown-preview ul ul { list-style-type: circle; }
.markdown-preview ol ol { list-style-type: lower-alpha; }
.markdown-preview li { margin: 0.5rem 0; line-height: 1.6; }
.markdown-preview code { background-color: rgb(229, 229, 229); color: rgb(31, 41, 55); padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.875rem; }
.markdown-preview pre { background-color: rgb(17, 24, 39); color: rgb(243, 244, 246); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; }
.markdown-preview pre code { background-color: transparent; color: inherit; padding: 0; }
.markdown-preview a { color: rgb(37, 99, 235); text-decoration: underline; }
.markdown-preview blockquote { border-left: 4px solid rgb(209, 213, 219); padding-left: 1rem; font-style: italic; margin: 1rem 0; }
.dark .markdown-preview code { background-color: rgb(55, 65, 81); color: rgb(243, 244, 246); }
.dark .markdown-preview a { color: rgb(96, 165, 250); }
.dark .markdown-preview blockquote { border-left-color: rgb(75, 85, 99); }
`}</style>
<div className="markdown-preview">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{content}
</ReactMarkdown>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+10 -8
View File
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { Search as SearchIcon, X as ClearIcon } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
type SearchInputProps = {
className?: string;
@@ -12,7 +13,8 @@ type SearchInputProps = {
};
export function SearchInput(props: SearchInputProps) {
const { className, handleSearch, value = '', placeholder = 'Search' } = props;
const { t } = useTranslation();
const { className, handleSearch, value = '', placeholder = t('search.placeholder') } = props;
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -22,14 +24,14 @@ export function SearchInput(props: SearchInputProps) {
type="button"
variant="outline"
size="icon"
aria-label={isMobileSearchOpen ? 'Close search' : 'Open search'}
aria-label={isMobileSearchOpen ? t('search.close') : t('search.open')}
onClick={() => {
setIsMobileSearchOpen((v) => !v);
if (!isMobileSearchOpen) {
setTimeout(() => inputRef.current?.focus(), 160);
}
}}
className="ml-auto shrink-0 md:hidden"
className="ms-auto shrink-0 md:hidden"
>
{isMobileSearchOpen ? <ClearIcon className="h-5 w-5" /> : <SearchIcon className="h-5 w-5" />}
</Button>
@@ -37,7 +39,7 @@ export function SearchInput(props: SearchInputProps) {
className={cn(
'relative w-fit max-w-lg',
isMobileSearchOpen
? 'absolute left-0 mt-4 block w-full md:relative md:mt-0 md:w-fit'
? 'absolute start-0 mt-4 block w-full md:relative md:mt-0 md:w-fit'
: 'hidden w-auto md:block',
className,
)}
@@ -45,14 +47,14 @@ export function SearchInput(props: SearchInputProps) {
{!!value ? (
<button
type="button"
aria-label="Clear search"
aria-label={t('search.clear')}
onClick={() => handleSearch('')}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 rounded p-1 hover:bg-muted"
className="absolute start-3 top-1/2 -translate-y-1/2 z-10 rounded p-1 hover:bg-muted"
>
<ClearIcon className="h-4 w-4 text-muted-foreground" />
</button>
) : (
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2">
<span className="pointer-events-none absolute start-3 top-1/2 z-10 -translate-y-1/2">
<SearchIcon className="text-muted-foreground h-4 w-4" />
</span>
)}
@@ -62,7 +64,7 @@ export function SearchInput(props: SearchInputProps) {
placeholder={placeholder}
value={value}
onChange={(ev) => handleSearch(ev.target.value)}
className="pl-10 pr-4 text-sm"
className="ps-10 pe-4 text-sm"
/>
</div>
</div>
+3 -1
View File
@@ -1,10 +1,12 @@
import { Select as ShadSelect, SelectContent, SelectGroup } from '@/components/ui/select';
import { SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const Select = (props: SelectProps) => {
const { t } = useTranslation();
const { options, value, onChange, onValueChange, label } = props;
const { name = '', className, placeholder = 'Select...', update } = props;
const { name = '', className, placeholder = t('select.placeholder'), update } = props;
if (!options) return null;
+3 -1
View File
@@ -1,6 +1,7 @@
import { useRef } from 'react';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslation } from '@/components/locales';
type TabsProps = {
tabList: { value: string; label: string }[];
@@ -8,6 +9,7 @@ type TabsProps = {
setActiveTab: (tab: string) => void;
};
const TabsComponent = (props: TabsProps) => {
const { t } = useTranslation();
const { tabList, activeTab, setActiveTab } = props;
const tabsListRef = useRef<HTMLDivElement>(null);
return (
@@ -38,7 +40,7 @@ const TabsComponent = (props: TabsProps) => {
<div className="block lg:hidden">
<Select value={activeTab} onValueChange={(v) => setActiveTab(v)}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Filter" />
<SelectValue placeholder={t('tabs.filterPlaceholder')} />
</SelectTrigger>
<SelectContent>
{tabList.map((f) => (
+81 -1
View File
@@ -1 +1,81 @@
{}
{
"button": {
"cancel": "Cancel",
"confirm": "Confirm",
"ok": "Ok",
"close": "Close"
},
"search": {
"placeholder": "Search",
"open": "Open search",
"close": "Close search",
"clear": "Clear search"
},
"select": {
"placeholder": "Select..."
},
"tabs": {
"filterPlaceholder": "Filter"
},
"commandBlock": {
"run": "Run in terminal",
"copy": "Copy command"
},
"colorPicker": {
"placeholder": "Hex colour"
},
"state": {
"loading": "Loading…"
},
"dataTable": {
"empty": "No Data Found.",
"pagination": {
"showingPage": "Showing page {{current}} of {{total}}",
"showingPageShort": "p {{current}}/{{total}}",
"rowsPerPage": "Rows per page",
"perPage": "{{size}} / page",
"perPageShort": "{{size}}/p",
"first": "First",
"prev": "Prev",
"next": "Next",
"last": "Last"
}
},
"pagination": {
"label": "Pagination",
"previous": "Previous",
"next": "Next",
"morePages": "More pages",
"goToPrevious": "Go to previous page",
"goToNext": "Go to next page"
},
"carousel": {
"previous": "Previous slide",
"next": "Next slide"
},
"breadcrumb": {
"more": "More"
},
"theme": {
"toggle": "Toggle color mode"
},
"error": {
"forbidden": {
"title": "Unauthorized Access",
"description": "You are not authorized to perform this action.",
"hint": "If you think this is an error, please contact your manager."
},
"unauthorized": {
"title": "Invalid Credentials",
"description": "Your access token has expired",
"hint": "Please sign in again"
},
"server": {
"title": "Server Error"
}
},
"logo": {
"icon": "Officer Icon",
"logo": "Officer Logo"
}
}
+81 -1
View File
@@ -1 +1,81 @@
{}
{
"button": {
"cancel": "Cancelar",
"confirm": "Confirmar",
"ok": "OK",
"close": "Fechar"
},
"search": {
"placeholder": "Pesquisar",
"open": "Abrir pesquisa",
"close": "Fechar pesquisa",
"clear": "Limpar pesquisa"
},
"select": {
"placeholder": "Selecionar..."
},
"tabs": {
"filterPlaceholder": "Filtrar"
},
"commandBlock": {
"run": "Executar no terminal",
"copy": "Copiar comando"
},
"colorPicker": {
"placeholder": "Cor hexadecimal"
},
"state": {
"loading": "A carregar…"
},
"dataTable": {
"empty": "Não foram encontrados dados.",
"pagination": {
"showingPage": "A mostrar página {{current}} de {{total}}",
"showingPageShort": "p {{current}}/{{total}}",
"rowsPerPage": "Linhas por página",
"perPage": "{{size}} / página",
"perPageShort": "{{size}}/p",
"first": "Primeira",
"prev": "Anterior",
"next": "Seguinte",
"last": "Última"
}
},
"pagination": {
"label": "Paginação",
"previous": "Anterior",
"next": "Seguinte",
"morePages": "Mais páginas",
"goToPrevious": "Ir para a página anterior",
"goToNext": "Ir para a página seguinte"
},
"carousel": {
"previous": "Diapositivo anterior",
"next": "Diapositivo seguinte"
},
"breadcrumb": {
"more": "Mais"
},
"theme": {
"toggle": "Alternar modo de cor"
},
"error": {
"forbidden": {
"title": "Acesso não autorizado",
"description": "Não tem autorização para executar esta ação.",
"hint": "Se acha que isto é um erro, contacte o seu gestor."
},
"unauthorized": {
"title": "Credenciais inválidas",
"description": "O seu token de acesso expirou",
"hint": "Inicie sessão novamente"
},
"server": {
"title": "Erro do servidor"
}
},
"logo": {
"icon": "Ícone Officer",
"logo": "Logótipo Officer"
}
}
+3 -1
View File
@@ -1,12 +1,14 @@
import { Moon, Sun } from 'lucide-react';
import { cn } from 'helpers/cn';
import { useColorMode } from './ThemeProvider';
import { useTranslation } from '@/components/locales';
type ThemeToggleProps = { colorDark?: boolean; colorLight?: boolean };
export const ThemeToggle = (props: ThemeToggleProps) => {
const { colorDark, colorLight } = props;
const { colorMode, setColorMode } = useColorMode();
const { t } = useTranslation();
const toggle = () => {
setColorMode(colorMode === 'dark' ? 'light' : 'dark');
@@ -26,7 +28,7 @@ export const ThemeToggle = (props: ThemeToggleProps) => {
colorDark && 'text-black',
)}
/>
<span className="sr-only">Toggle color mode</span>
<span className="sr-only">{t('theme.toggle')}</span>
</div>
);
};
+15 -11
View File
@@ -3,6 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Breadcrumb = React.forwardRef<
HTMLElement,
@@ -66,17 +67,20 @@ const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentP
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
const { t } = useTranslation();
return (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">{t('breadcrumb.more')}</span>
</span>
);
};
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export {
+7 -4
View File
@@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
@@ -168,6 +169,7 @@ CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
const { t } = useTranslation();
return (
<Button
@@ -177,7 +179,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
? '-start-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
@@ -186,7 +188,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
<span className="sr-only">{t('carousel.previous')}</span>
</Button>
);
},
@@ -196,6 +198,7 @@ CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
const { t } = useTranslation();
return (
<Button
@@ -205,7 +208,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
? '-end-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
@@ -214,7 +217,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
<span className="sr-only">{t('carousel.next')}</span>
</Button>
);
},
+23 -19
View File
@@ -3,6 +3,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Dialog = DialogPrimitive.Root;
@@ -30,25 +31,28 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
>(({ className, children, ...props }, ref) => {
const { t } = useTranslation();
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">{t('button.close')}</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
+3 -1
View File
@@ -3,10 +3,12 @@ import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useColorMode } from '@/components/ui/ThemeProvider';
import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, ...props }, ref) => {
const { colorMode, setColorMode } = useColorMode();
const { t } = useTranslation();
return (
<Button
@@ -19,7 +21,7 @@ export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, Reac
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle color mode</span>
<span className="sr-only">{t('theme.toggle')}</span>
</Button>
);
},
+49 -26
View File
@@ -4,15 +4,19 @@ import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ButtonProps } from '@/components/ui/button';
import { buttonVariants } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => {
const { t } = useTranslation();
return (
<nav
role="navigation"
aria-label={t('pagination.label')}
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
};
Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
@@ -47,28 +51,47 @@ const PaginationLink = ({ className, isActive, size = 'icon', ...props }: Pagina
);
PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to previous page" size="default" className={cn('gap-1 pl-2.5', className)} {...props}>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => {
const { t } = useTranslation();
return (
<PaginationLink
aria-label={t('pagination.goToPrevious')}
size="default"
className={cn('gap-1 ps-2.5', className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>{t('pagination.previous')}</span>
</PaginationLink>
);
};
PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to next page" size="default" className={cn('gap-1 pr-2.5', className)} {...props}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => {
const { t } = useTranslation();
return (
<PaginationLink
aria-label={t('pagination.goToNext')}
size="default"
className={cn('gap-1 pe-2.5', className)}
{...props}
>
<span>{t('pagination.next')}</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
};
PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
const { t } = useTranslation();
return (
<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">{t('pagination.morePages')}</span>
</span>
);
};
PaginationEllipsis.displayName = 'PaginationEllipsis';
export {
+16 -12
View File
@@ -4,6 +4,7 @@ import { X } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Sheet = SheetPrimitive.Root;
@@ -51,18 +52,21 @@ interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
),
({ side = 'right', className, children, ...props }, ref) => {
const { t } = useTranslation();
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">{t('button.close')}</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
},
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
@@ -1,212 +0,0 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Play } from 'lucide-react';
export default function StartConfettiButton({
isDraftLike,
onStart,
className,
label = 'Start',
}: {
isDraftLike: boolean;
onStart?: () => void;
className?: string;
label?: string;
}) {
const [showConfetti, setShowConfetti] = useState(false);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const rafRef = useRef<number | null>(null);
// ---- Confetti Engine (inline, light) ----
const runConfetti = useCallback(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d')!;
const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
// size canvas
const resize = () => {
const { innerWidth: w, innerHeight: h } = window;
canvas.width = Math.floor(w * DPR);
canvas.height = Math.floor(h * DPR);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
};
resize();
const onResize = () => resize();
window.addEventListener('resize', onResize);
const colors = [
'#ef4444', // red-500
'#f59e0b', // amber-500
'#10b981', // emerald-500
'#3b82f6', // blue-500
'#8b5cf6', // violet-500
'#ec4899', // pink-500
'#22c55e', // green-500
'#eab308', // yellow-500
];
type Particle = {
x: number;
y: number;
w: number;
h: number;
vx: number;
vy: number;
rot: number;
rotSpeed: number;
tilt: number;
tiltSpeed: number;
color: string;
alpha: number;
decay: number;
shape: 'rect' | 'triangle';
};
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
const particles: Particle[] = [];
const bursts = 5;
const perBurst = 80;
const spread = Math.PI / 3;
const gravity = 0.25;
const air = 0.002;
const floorFriction = 0.96;
for (let b = 0; b < bursts; b++) {
const originX = (window.innerWidth / (bursts + 1)) * (b + 1);
const originY = window.innerHeight * 0.15;
const angleBase = -Math.PI / 2;
for (let i = 0; i < perBurst; i++) {
const angle = angleBase + rand(-spread, spread);
const speed = rand(6, 12);
particles.push({
x: originX + rand(-8, 8),
y: originY + rand(-8, 8),
w: rand(4, 8),
h: rand(6, 14),
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
rot: rand(0, Math.PI * 2),
rotSpeed: rand(-0.2, 0.2),
tilt: rand(-0.8, 0.8),
tiltSpeed: rand(-0.02, 0.02),
color: colors[(Math.random() * colors.length) | 0] ?? '#ef4444',
alpha: 1,
decay: rand(0.004, 0.01),
shape: Math.random() < 0.3 ? 'triangle' : 'rect',
});
}
}
const tick = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const p of particles) {
// physics
p.vy += gravity;
p.vx *= 1 - air;
p.vy *= 1 - air * 0.5;
p.x += p.vx;
p.y += p.vy;
p.rot += p.rotSpeed;
p.tilt += p.tiltSpeed;
p.alpha -= p.decay;
// floor bounce & friction
const floor = window.innerHeight - 6;
if (p.y > floor) {
p.y = floor;
p.vy *= -0.45;
p.vx *= floorFriction;
p.rotSpeed *= floorFriction;
p.tiltSpeed *= floorFriction;
}
// draw
if (p.alpha > 0) {
ctx.globalAlpha = Math.max(0, p.alpha);
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot + p.tilt);
ctx.fillStyle = p.color;
if (p.shape === 'rect') {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
} else {
ctx.beginPath();
ctx.moveTo(0, -p.h / 2);
ctx.lineTo(p.w / 2, p.h / 2);
ctx.lineTo(-p.w / 2, p.h / 2);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
}
const alive = particles.some((p) => p.alpha > 0.02);
if (alive) {
rafRef.current = requestAnimationFrame(tick);
} else {
// cleanup
ctx.clearRect(0, 0, canvas.width, canvas.height);
window.removeEventListener('resize', onResize);
}
};
tick();
}, []);
const handleStart = () => {
onStart?.();
if (isDraftLike) {
setShowConfetti(true);
}
};
useEffect(() => {
if (!showConfetti) return;
runConfetti();
const t = setTimeout(() => {
setShowConfetti(false);
}, 2200);
return () => {
clearTimeout(t);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
};
}, [showConfetti, runConfetti]);
return (
<>
<Button
size="sm"
onClick={handleStart}
className={`min-w-24 ${isDraftLike ? 'bg-green-600 text-white hover:bg-green-700' : ''} ${className ?? ''}`}
disabled={!isDraftLike}
title={isDraftLike ? 'Start the experiment' : 'Only available for Draft/Created'}
>
<Play className="w-4 h-4 mr-2" />
{label}
</Button>
{/* Confetti Overlay */}
{showConfetti && (
<div
className="fixed inset-0 z-100 pointer-events-none mask-[radial-gradient(80%_80%_at_50%_40%,#000,transparent)]"
aria-hidden
>
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
<div className="absolute inset-x-0 top-0 h-32 bg-linear-to-b from-white/60 to-transparent" />
</div>
)}
</>
);
}
@@ -96,6 +96,10 @@ const startBrief = (name: string): string =>
'If you CAN — say what you understood, in your own words, and ask me to confirm or correct it.',
'If you CANNOT — ask me what you should be watching for here. One question, and wait.',
'',
'Once you are running, you owe me exactly two messages here per event, and I should never have to ask',
'for them: one line when the thing you watch for happens, and a short report when the work is done.',
'The job panel is not where I read things. The skill explains how to wake this conversation yourself.',
'',
'Either way, say it plainly. No headings, no bullet points, no file paths, no commit hashes, no tag',
'names. Do not open with "Oriented" and do not tell me you verified anything — I asked you to look,',
'so saying you looked is not news. Length is whatever the answer honestly needs and no more.',
+4 -3
View File
@@ -49,9 +49,10 @@ handling.
- `Dashboards/DashboardPreview`'s `NamePanel` (name + description) has its submit button in a
_different_, independently-mounted panel (`CreatePanel`) elsewhere in the workspace layout tree — a
`<form>` can't span two panels without restructuring the wizard.
- `ColorPicker` and `MarkdownEditor` have zero call sites anywhere in the repo right now; both are
shaped to be embedded in a future caller's own form (uncontrolled inputs with `name` attributes, or a
plain controlled textarea with no submit of its own), so there's nothing to wrap yet.
- `ColorPicker` has zero call sites anywhere in the repo right now; it is shaped to be embedded in a
future caller's own form (uncontrolled inputs with `name` attributes), so there's nothing to wrap yet.
`MarkdownEditor` was named here too and was deleted on 2026-08-23 during the localization pass — it
had no call sites and hardcoded another product's logo URL.
- `widgets/Weather`'s `LocationSetup` is a live autocomplete with several result rows and no single
obvious Enter target (which of N matches?).