Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62ab080725 | ||
|
|
2395576c88 | ||
|
|
fee84c7a6b | ||
|
|
e71848d996 | ||
|
|
d8238855fc |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { formatReport } from './eval-report';
|
||||
import { loadGoldCases, parseGoldCase, type GoldCase } from './gold-case';
|
||||
@@ -317,6 +317,10 @@ describe('eval:voice stays out of CI', () => {
|
||||
'.gitea',
|
||||
'workflows',
|
||||
);
|
||||
// The backend image builds without the repo root: nothing to check there.
|
||||
if (!existsSync(workflows)) {
|
||||
return;
|
||||
}
|
||||
const files = readdirSync(workflows).filter((name) =>
|
||||
/\.ya?ml$/.test(name),
|
||||
);
|
||||
|
||||
@@ -24,11 +24,13 @@ const NOTHING_NEW = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The job evaluates the whole database. The e2e files share it and run in
|
||||
* parallel, so the evaluation runs on days in 2011 and 2012: no other file has
|
||||
* records there (autopilot and reminders own 2001; this file used to share it,
|
||||
* so its "nothing new" counts picked up their colonies and its phenology step
|
||||
* wrote season rows for their apiaries), and the events of this file stay its own. Called through
|
||||
* The job evaluates the whole database, and the expiry step expires EVERY open
|
||||
* suggestion whose expiresAt lies before the given day. The e2e files share
|
||||
* the database and run in parallel, so this file runs on days in 1991 and
|
||||
* 1992: earlier than any date any other file uses (autopilot and reminders
|
||||
* use 2001, others 2002 and later). A later year would let the expiry step
|
||||
* expire their open suggestions (and count them in our totals), and an
|
||||
* "own" year in between would still see 2001's records. Called through
|
||||
* TimeEventsStep.run(day) instead of runCli, which would also write JobRun
|
||||
* rows that test/jobs.e2e-spec.ts counts.
|
||||
*/
|
||||
@@ -68,7 +70,7 @@ describe('daily-evaluation (e2e)', () => {
|
||||
phenologyStep = app.get(PhenologyStep);
|
||||
expiryStep = app.get(SuggestionsExpiryStep);
|
||||
// The real AutopilotStep, but on the colonies of this file only: other
|
||||
// files share the database and must not be reconciled on a 2011 day.
|
||||
// files share the database and must not be reconciled on a 1991 day.
|
||||
autopilotStep = new AutopilotStep(
|
||||
{
|
||||
colony: {
|
||||
@@ -109,18 +111,18 @@ describe('daily-evaluation (e2e)', () => {
|
||||
});
|
||||
ids[`queen:${name}`] = created.id;
|
||||
};
|
||||
await queen('aged', { hatchYear: 2009, status: 'ACTIVE' });
|
||||
await queen('young', { hatchYear: 2010, status: 'ACTIVE' });
|
||||
await queen('reserve', { hatchYear: 2009, status: 'RESERVE' });
|
||||
await queen('aged', { hatchYear: 1989, status: 'ACTIVE' });
|
||||
await queen('young', { hatchYear: 1990, status: 'ACTIVE' });
|
||||
await queen('reserve', { hatchYear: 1989, status: 'RESERVE' });
|
||||
|
||||
// Living, created 2011-04-20 and never measured: varroa due 2011-05-18.
|
||||
// Living, created 1991-04-20 and never measured: varroa due 1991-05-18.
|
||||
const living = await prisma.colony.create({
|
||||
data: {
|
||||
code: `DE-E2E-${suffix}-living`,
|
||||
apiaryId: apiary.id,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
currentQueenId: ids['queen:aged'],
|
||||
createdAt: d('2011-04-20'),
|
||||
createdAt: d('1991-04-20'),
|
||||
},
|
||||
});
|
||||
ids.living = living.id;
|
||||
@@ -130,15 +132,15 @@ describe('daily-evaluation (e2e)', () => {
|
||||
apiaryId: apiary.id,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
status: 'DECEASED',
|
||||
createdAt: d('2011-04-20'),
|
||||
createdAt: d('1991-04-20'),
|
||||
},
|
||||
});
|
||||
ids.dead = dead.id;
|
||||
// Inspected 2011-04-28, in the swarm season (every 7 days): the next
|
||||
// inspection is due 2011-05-05 and overdue on 2011-05-06.
|
||||
// Inspected 1991-04-28, in the swarm season (every 7 days): the next
|
||||
// inspection is due 1991-05-05 and overdue on 1991-05-06.
|
||||
for (const colonyId of [living.id, dead.id]) {
|
||||
await prisma.inspection.create({
|
||||
data: { colonyId, userId: user.id, date: d('2011-04-28') },
|
||||
data: { colonyId, userId: user.id, date: d('1991-04-28') },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,18 +161,18 @@ describe('daily-evaluation (e2e)', () => {
|
||||
});
|
||||
ids[`treatment:${name}`] = created.id;
|
||||
};
|
||||
// 2011-05-10 + 7 = 2011-05-17, in the window of 2011-05-20.
|
||||
// 1991-05-10 + 7 = 1991-05-17, in the window of 1991-05-20.
|
||||
await treatment('ending', {
|
||||
date: d('2011-05-08'),
|
||||
endDate: d('2011-05-10'),
|
||||
date: d('1991-05-08'),
|
||||
endDate: d('1991-05-10'),
|
||||
withdrawalPeriodDays: 7,
|
||||
});
|
||||
// 2011-05-01 + 7 = 2011-05-08, before the window.
|
||||
// 1991-05-01 + 7 = 1991-05-08, before the window.
|
||||
await treatment('ended-earlier', {
|
||||
date: d('2011-05-01'),
|
||||
date: d('1991-05-01'),
|
||||
withdrawalPeriodDays: 7,
|
||||
});
|
||||
await treatment('no-withdrawal', { date: d('2011-05-15') });
|
||||
await treatment('no-withdrawal', { date: d('1991-05-15') });
|
||||
|
||||
const task = async (
|
||||
name: string,
|
||||
@@ -181,8 +183,8 @@ describe('daily-evaluation (e2e)', () => {
|
||||
});
|
||||
ids[`task:${name}`] = created.id;
|
||||
};
|
||||
await task('open', { dueDate: d('2011-05-18'), status: 'OPEN' });
|
||||
await task('done', { dueDate: d('2011-05-18'), status: 'DONE' });
|
||||
await task('open', { dueDate: d('1991-05-18'), status: 'OPEN' });
|
||||
await task('done', { dueDate: d('1991-05-18'), status: 'DONE' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -224,10 +226,10 @@ describe('daily-evaluation (e2e)', () => {
|
||||
});
|
||||
|
||||
it('running the job twice yields exactly one WithdrawalEnded per treatment and day', async () => {
|
||||
const first = await step.run('2011-05-20');
|
||||
const second = await step.run('2011-05-20');
|
||||
const first = await step.run('1991-05-20');
|
||||
const second = await step.run('1991-05-20');
|
||||
// The next day the occurrence is still in the window - and still known.
|
||||
const nextDay = await step.run('2011-05-21');
|
||||
const nextDay = await step.run('1991-05-21');
|
||||
|
||||
expect(first.WithdrawalEnded).toBeGreaterThanOrEqual(1);
|
||||
expect(second).toEqual(NOTHING_NEW);
|
||||
@@ -236,8 +238,8 @@ describe('daily-evaluation (e2e)', () => {
|
||||
{
|
||||
type: 'WithdrawalEnded',
|
||||
aggregateType: 'Treatment',
|
||||
payload: { colonyId: ids.living, endedOn: '2011-05-17' },
|
||||
dedupeKey: `WithdrawalEnded:${ids['treatment:ending']}:2011-05-17`,
|
||||
payload: { colonyId: ids.living, endedOn: '1991-05-17' },
|
||||
dedupeKey: `WithdrawalEnded:${ids['treatment:ending']}:1991-05-17`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
@@ -280,11 +282,11 @@ describe('daily-evaluation (e2e)', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.notification.deleteMany({
|
||||
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2011' },
|
||||
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:1991' },
|
||||
});
|
||||
await prisma.authorityNotice.deleteMany({ where: { year: 2011 } });
|
||||
await prisma.authorityNotice.deleteMany({ where: { year: 1991 } });
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: 'tsk:2011' },
|
||||
where: { aggregateId: 'tsk:1991' },
|
||||
});
|
||||
await prisma.complianceSettings.update({
|
||||
where: { id: 'singleton' },
|
||||
@@ -293,25 +295,25 @@ describe('daily-evaluation (e2e)', () => {
|
||||
});
|
||||
|
||||
async function expectOneDraftAndNotices(): Promise<void> {
|
||||
expect(await eventsOf('tsk:2011')).toEqual([
|
||||
expect(await eventsOf('tsk:1991')).toEqual([
|
||||
{
|
||||
type: 'TskReferenceDayReached',
|
||||
aggregateType: 'Compliance',
|
||||
payload: { year: 2011, referenceDay: '2011-03-03' },
|
||||
dedupeKey: 'TskReferenceDayReached:tsk:2011:2011-03-03',
|
||||
payload: { year: 1991, referenceDay: '1991-03-03' },
|
||||
dedupeKey: 'TskReferenceDayReached:tsk:1991:1991-03-03',
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
const drafts = await prisma.authorityNotice.findMany({
|
||||
where: { kind: 'TSK_REPORT', year: 2011 },
|
||||
where: { kind: 'TSK_REPORT', year: 1991 },
|
||||
});
|
||||
expect(drafts).toHaveLength(1);
|
||||
expect(drafts[0]).toMatchObject({
|
||||
status: 'DRAFT',
|
||||
openKey: 'TSK_REPORT:2011',
|
||||
openKey: 'TSK_REPORT:1991',
|
||||
});
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2011' },
|
||||
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:1991' },
|
||||
select: { userId: true, priority: true, category: true, message: true },
|
||||
});
|
||||
// One copy per recipient, written once - and every permitted user that
|
||||
@@ -333,17 +335,17 @@ describe('daily-evaluation (e2e)', () => {
|
||||
category: 'COMPLIANCE',
|
||||
});
|
||||
expect(row.message).toMatch(
|
||||
/^TSK-Stichtag 03\.03\.2011: \d+ Völker gezählt\. Bitte prüfen und melden\.$/,
|
||||
/^TSK-Stichtag 03\.03\.1991: \d+ Völker gezählt\. Bitte prüfen und melden\.$/,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
it('daily evaluation twice on the reference day: one event, one draft', async () => {
|
||||
const first = await step.run('2011-03-03');
|
||||
const second = await step.run('2011-03-03');
|
||||
await drainDomainEvents(app, ['tsk:2011']);
|
||||
await step.run('2011-03-04');
|
||||
await drainDomainEvents(app, ['tsk:2011']);
|
||||
const first = await step.run('1991-03-03');
|
||||
const second = await step.run('1991-03-03');
|
||||
await drainDomainEvents(app, ['tsk:1991']);
|
||||
await step.run('1991-03-04');
|
||||
await drainDomainEvents(app, ['tsk:1991']);
|
||||
|
||||
expect(first.TskReferenceDayReached).toBe(1);
|
||||
expect(second.TskReferenceDayReached).toBe(0);
|
||||
@@ -351,9 +353,9 @@ describe('daily-evaluation (e2e)', () => {
|
||||
});
|
||||
|
||||
it('catches up a missed reference day on the 5th exactly once', async () => {
|
||||
const catchUp = await step.run('2011-03-05');
|
||||
const again = await step.run('2011-03-05');
|
||||
await drainDomainEvents(app, ['tsk:2011']);
|
||||
const catchUp = await step.run('1991-03-05');
|
||||
const again = await step.run('1991-03-05');
|
||||
await drainDomainEvents(app, ['tsk:1991']);
|
||||
|
||||
expect(catchUp.TskReferenceDayReached).toBe(1);
|
||||
expect(again.TskReferenceDayReached).toBe(0);
|
||||
@@ -362,14 +364,14 @@ describe('daily-evaluation (e2e)', () => {
|
||||
});
|
||||
|
||||
it('records due tasks and varroa checks only for open tasks and living colonies', async () => {
|
||||
await step.run('2011-05-20');
|
||||
await step.run('1991-05-20');
|
||||
|
||||
expect(await eventsOf(ids['task:open'])).toEqual([
|
||||
{
|
||||
type: 'TaskDue',
|
||||
aggregateType: 'Task',
|
||||
payload: { dueOn: '2011-05-18' },
|
||||
dedupeKey: `TaskDue:${ids['task:open']}:2011-05-18`,
|
||||
payload: { dueOn: '1991-05-18' },
|
||||
dedupeKey: `TaskDue:${ids['task:open']}:1991-05-18`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
@@ -379,12 +381,12 @@ describe('daily-evaluation (e2e)', () => {
|
||||
type: 'VarroaMeasurementDue',
|
||||
aggregateType: 'Colony',
|
||||
payload: {
|
||||
dueOn: '2011-05-18',
|
||||
dueOn: '1991-05-18',
|
||||
lastMeasuredOn: null,
|
||||
kind: 'INTERVAL',
|
||||
anchor: 'none',
|
||||
},
|
||||
dedupeKey: `VarroaMeasurementDue:${ids.living}:2011-05-18`,
|
||||
dedupeKey: `VarroaMeasurementDue:${ids.living}:1991-05-18`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
@@ -392,9 +394,9 @@ describe('daily-evaluation (e2e)', () => {
|
||||
});
|
||||
|
||||
it('InspectionOverdue follows the segment interval', async () => {
|
||||
await step.run('2011-05-12');
|
||||
await step.run('1991-05-12');
|
||||
|
||||
// SCHWARMZEIT since 2011-04-15: 2011-04-28 + 7 = 2011-05-05, not + 21.
|
||||
// SCHWARMZEIT since 1991-04-15: 1991-04-28 + 7 = 1991-05-05, not + 21.
|
||||
expect(
|
||||
(await eventsOf(ids.living)).filter(
|
||||
(event) => event.type === 'InspectionOverdue',
|
||||
@@ -404,11 +406,11 @@ describe('daily-evaluation (e2e)', () => {
|
||||
type: 'InspectionOverdue',
|
||||
aggregateType: 'Colony',
|
||||
payload: {
|
||||
dueOn: '2011-05-05',
|
||||
lastInspectedOn: '2011-04-28',
|
||||
dueOn: '1991-05-05',
|
||||
lastInspectedOn: '1991-04-28',
|
||||
firstOfYear: false,
|
||||
},
|
||||
dedupeKey: `InspectionOverdue:${ids.living}:2011-05-06`,
|
||||
dedupeKey: `InspectionOverdue:${ids.living}:1991-05-06`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
@@ -416,50 +418,50 @@ describe('daily-evaluation (e2e)', () => {
|
||||
});
|
||||
|
||||
it('records no inspection in WINTERRUHE and the first one the day after FRUEHJAHR begins', async () => {
|
||||
await step.run('2012-03-10');
|
||||
await step.run('1992-03-10');
|
||||
expect(
|
||||
(await eventsOf(ids.living)).filter((event) =>
|
||||
event.dedupeKey?.endsWith(':2012-03-21'),
|
||||
event.dedupeKey?.endsWith(':1992-03-21'),
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
// FRUEHJAHR begins 2012-03-20; the colony was last inspected in 2011.
|
||||
await step.run('2012-03-21');
|
||||
// FRUEHJAHR begins 1992-03-20; the colony was last inspected in 1991.
|
||||
await step.run('1992-03-21');
|
||||
expect(
|
||||
(await eventsOf(ids.living)).filter((event) =>
|
||||
event.dedupeKey?.endsWith(':2012-03-21'),
|
||||
event.dedupeKey?.endsWith(':1992-03-21'),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'InspectionOverdue',
|
||||
aggregateType: 'Colony',
|
||||
payload: {
|
||||
dueOn: '2012-03-20',
|
||||
lastInspectedOn: '2011-04-28',
|
||||
dueOn: '1992-03-20',
|
||||
lastInspectedOn: '1991-04-28',
|
||||
firstOfYear: true,
|
||||
},
|
||||
dedupeKey: `InspectionOverdue:${ids.living}:2012-03-21`,
|
||||
dedupeKey: `InspectionOverdue:${ids.living}:1992-03-21`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports the current queen of a living colony once she is aged, on any job day of the year', async () => {
|
||||
await step.run('2011-09-09');
|
||||
await step.run('2011-09-10');
|
||||
await step.run('1991-09-09');
|
||||
await step.run('1991-09-10');
|
||||
|
||||
// One per queen and year: the 2012 run of the test above added its own.
|
||||
// One per queen and year: the 1992 run of the test above added its own.
|
||||
const events = await eventsOf(ids['queen:aged']);
|
||||
expect(events.map((event) => event.dedupeKey)).toEqual([
|
||||
`QueenAged:${ids['queen:aged']}:2011-01-01`,
|
||||
`QueenAged:${ids['queen:aged']}:2012-01-01`,
|
||||
`QueenAged:${ids['queen:aged']}:1991-01-01`,
|
||||
`QueenAged:${ids['queen:aged']}:1992-01-01`,
|
||||
]);
|
||||
expect(events.slice(0, 1)).toEqual([
|
||||
{
|
||||
type: 'QueenAged',
|
||||
aggregateType: 'Queen',
|
||||
payload: { hatchYear: 2009, colonyId: ids.living },
|
||||
dedupeKey: `QueenAged:${ids['queen:aged']}:2011-01-01`,
|
||||
payload: { hatchYear: 1989, colonyId: ids.living },
|
||||
dedupeKey: `QueenAged:${ids['queen:aged']}:1991-01-01`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
@@ -468,7 +470,7 @@ describe('daily-evaluation (e2e)', () => {
|
||||
});
|
||||
|
||||
describe('phenology step', () => {
|
||||
const DAY = '2011-04-30';
|
||||
const DAY = '1991-04-30';
|
||||
let weatherApiaryId = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -483,8 +485,8 @@ describe('daily-evaluation (e2e)', () => {
|
||||
// 10 degrees every day from January 1st to April 29th: GTS 965.
|
||||
const days: Prisma.ApiaryWeatherDayCreateManyInput[] = [];
|
||||
for (
|
||||
let t = d('2011-01-01').getTime();
|
||||
t <= d('2011-04-29').getTime();
|
||||
let t = d('1991-01-01').getTime();
|
||||
t <= d('1991-04-29').getTime();
|
||||
t += 86_400_000
|
||||
) {
|
||||
days.push({
|
||||
@@ -499,7 +501,7 @@ describe('daily-evaluation (e2e)', () => {
|
||||
data: {
|
||||
apiaryId: apiary.id,
|
||||
phase: 'HOLUNDER',
|
||||
date: d('2011-04-29'),
|
||||
date: d('1991-04-29'),
|
||||
userId: ids.user,
|
||||
},
|
||||
});
|
||||
@@ -512,8 +514,8 @@ describe('daily-evaluation (e2e)', () => {
|
||||
await prisma.phenologyReport.deleteMany({
|
||||
where: { apiaryId: weatherApiaryId },
|
||||
});
|
||||
// Rows of 2011 would feed the season of the other tests of this file.
|
||||
await prisma.apiaryPhenology.deleteMany({ where: { year: 2011 } });
|
||||
// Rows of 1991 would feed the season of the other tests of this file.
|
||||
await prisma.apiaryPhenology.deleteMany({ where: { year: 1991 } });
|
||||
await prisma.apiary.deleteMany({ where: { id: weatherApiaryId } });
|
||||
});
|
||||
|
||||
@@ -532,7 +534,7 @@ describe('daily-evaluation (e2e)', () => {
|
||||
|
||||
expect(Number(result.phenologyApiaries)).toBeGreaterThanOrEqual(2);
|
||||
const rows = await prisma.apiaryPhenology.findMany({
|
||||
where: { apiaryId: { in: [ids.apiary, weatherApiaryId] }, year: 2011 },
|
||||
where: { apiaryId: { in: [ids.apiary, weatherApiaryId] }, year: 1991 },
|
||||
orderBy: { apiaryId: 'asc' },
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
@@ -547,7 +549,7 @@ describe('daily-evaluation (e2e)', () => {
|
||||
// A report for HOLUNDER beats GTS and calendar.
|
||||
expect(byApiary.get(weatherApiaryId)).toMatchObject({
|
||||
gts: 965,
|
||||
gtsThroughDay: d('2011-04-29'),
|
||||
gtsThroughDay: d('1991-04-29'),
|
||||
estimatedPhase: 'APFEL',
|
||||
reportedPhase: 'HOLUNDER',
|
||||
effectivePhase: 'HOLUNDER',
|
||||
@@ -581,25 +583,25 @@ describe('daily-evaluation (e2e)', () => {
|
||||
});
|
||||
|
||||
describe('autopilot and suggestions-expiry steps', () => {
|
||||
/** Living colony, inspected 2011-05-03 and measured 2011-05-10. */
|
||||
/** Living colony, inspected 1991-05-03 and measured 1991-05-10. */
|
||||
async function lateColony(name: string): Promise<string> {
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `DE-E2E-${suffix}-ap-${name}`,
|
||||
apiaryId: ids.apiary,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
createdAt: d('2011-04-20'),
|
||||
createdAt: d('1991-04-20'),
|
||||
},
|
||||
});
|
||||
autopilotColonyIds.push(colony.id);
|
||||
await prisma.inspection.create({
|
||||
data: { colonyId: colony.id, userId: ids.user, date: d('2011-05-03') },
|
||||
data: { colonyId: colony.id, userId: ids.user, date: d('1991-05-03') },
|
||||
});
|
||||
await prisma.varroaMeasurement.create({
|
||||
data: {
|
||||
colonyId: colony.id,
|
||||
userId: ids.user,
|
||||
date: d('2011-05-10'),
|
||||
date: d('1991-05-10'),
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
},
|
||||
});
|
||||
@@ -615,10 +617,10 @@ describe('daily-evaluation (e2e)', () => {
|
||||
it('running autopilot and expiry twice yields the same suggestion counts', async () => {
|
||||
const colonyId = await lateColony('twice');
|
||||
|
||||
const first = await autopilotStep.run('2011-05-12');
|
||||
const firstExpiry = await expiryStep.run('2011-05-12');
|
||||
const second = await autopilotStep.run('2011-05-12');
|
||||
const secondExpiry = await expiryStep.run('2011-05-12');
|
||||
const first = await autopilotStep.run('1991-05-12');
|
||||
const firstExpiry = await expiryStep.run('1991-05-12');
|
||||
const second = await autopilotStep.run('1991-05-12');
|
||||
const secondExpiry = await expiryStep.run('1991-05-12');
|
||||
|
||||
expect(first).toMatchObject({
|
||||
autopilotColonies: 1,
|
||||
@@ -637,13 +639,13 @@ describe('daily-evaluation (e2e)', () => {
|
||||
|
||||
it('an OPEN suggestion past expiresAt becomes EXPIRED', async () => {
|
||||
const colonyId = await lateColony('expires');
|
||||
await autopilotStep.run('2011-05-12');
|
||||
await autopilotStep.run('1991-05-12');
|
||||
|
||||
// expiresAt = 2011-06-11
|
||||
expect(await expiryStep.run('2011-06-11')).toEqual({
|
||||
// expiresAt = 1991-06-11
|
||||
expect(await expiryStep.run('1991-06-11')).toEqual({
|
||||
suggestionsExpired: 0,
|
||||
});
|
||||
expect(await expiryStep.run('2011-06-12')).toEqual({
|
||||
expect(await expiryStep.run('1991-06-12')).toEqual({
|
||||
suggestionsExpired: expect.any(Number) as number,
|
||||
});
|
||||
|
||||
@@ -653,10 +655,10 @@ describe('daily-evaluation (e2e)', () => {
|
||||
|
||||
it('an expired INSPECT suggestion of the same anchor is proposed again on the next sync (dedupeKey was released)', async () => {
|
||||
const colonyId = await lateColony('again');
|
||||
await autopilotStep.run('2011-05-12');
|
||||
await expiryStep.run('2011-06-12');
|
||||
await autopilotStep.run('1991-05-12');
|
||||
await expiryStep.run('1991-06-12');
|
||||
|
||||
const result = await autopilotStep.run('2011-06-12');
|
||||
const result = await autopilotStep.run('1991-06-12');
|
||||
|
||||
expect(result.suggestionsOpened).toBeGreaterThanOrEqual(1);
|
||||
expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual(
|
||||
@@ -668,13 +670,13 @@ describe('daily-evaluation (e2e)', () => {
|
||||
const colonyId = await lateColony('overdue20');
|
||||
await prisma.inspection.updateMany({
|
||||
where: { colonyId },
|
||||
data: { date: d('2011-04-10') },
|
||||
data: { date: d('1991-04-10') },
|
||||
});
|
||||
|
||||
await step.run('2011-05-12');
|
||||
await autopilotStep.run('2011-05-12');
|
||||
await step.run('1991-05-12');
|
||||
await autopilotStep.run('1991-05-12');
|
||||
|
||||
// Due 2011-04-17: the event of 04-18 is outside the 7-day catch-up.
|
||||
// Due 1991-04-17: the event of 04-18 is outside the 7-day catch-up.
|
||||
expect(
|
||||
await prisma.domainEvent.count({
|
||||
where: { aggregateId: colonyId, type: 'InspectionOverdue' },
|
||||
@@ -682,19 +684,19 @@ describe('daily-evaluation (e2e)', () => {
|
||||
).toBe(0);
|
||||
const [suggestion] = await inspectSuggestions(colonyId);
|
||||
expect(suggestion).toMatchObject({ status: 'OPEN', priority: 'HIGH' });
|
||||
expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('2011-04-17');
|
||||
expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('1991-04-17');
|
||||
});
|
||||
|
||||
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => {
|
||||
const colonyId = await lateColony('returns');
|
||||
await autopilotStep.run('2011-05-12');
|
||||
await autopilotStep.run('1991-05-12');
|
||||
const fresh = await prisma.inspection.create({
|
||||
data: { colonyId, userId: ids.user, date: d('2011-05-12') },
|
||||
data: { colonyId, userId: ids.user, date: d('1991-05-12') },
|
||||
});
|
||||
const resolved = await autopilotStep.run('2011-05-12');
|
||||
const resolved = await autopilotStep.run('1991-05-12');
|
||||
await prisma.inspection.delete({ where: { id: fresh.id } });
|
||||
|
||||
const returned = await autopilotStep.run('2011-05-12');
|
||||
const returned = await autopilotStep.run('1991-05-12');
|
||||
|
||||
expect(resolved.suggestionsResolved).toBeGreaterThanOrEqual(1);
|
||||
expect(returned.suggestionsOpened).toBeGreaterThanOrEqual(1);
|
||||
|
||||
@@ -39,8 +39,13 @@ describe('Jobs (e2e)', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Only the jobs this file runs: other files (rekey, app-backup, voice)
|
||||
// record their own runs in the same window and read them back.
|
||||
const runs = await prisma.jobRun.findMany({
|
||||
where: { startedAt: { gte: testStart } },
|
||||
where: {
|
||||
job: { in: ['cleanup', 'daily-evaluation', 'task-templates'] },
|
||||
startedAt: { gte: testStart },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const runIds = [...jobRunIds, ...runs.map((run) => run.id)];
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"eslint-config-next": "16.3.8",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^30.0.1",
|
||||
"sharp": "0.35.5",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.11"
|
||||
@@ -1781,8 +1782,8 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -11158,8 +11159,8 @@
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
@@ -11208,8 +11209,8 @@
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"check:3d-budget": "node scripts/check-3d-budget.mjs"
|
||||
"check:3d-budget": "node scripts/check-3d-budget.mjs",
|
||||
"icons": "node scripts/generate-icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
@@ -52,6 +53,7 @@
|
||||
"eslint-config-next": "16.3.8",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^30.0.1",
|
||||
"sharp": "0.35.5",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.11"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<!-- ApiCore mark (spec 12.1): a capped honeycomb cell. Master for scripts/generate-icons.mjs;
|
||||
the geometry equals BRAND_CELL_PATHS in src/components/brand-mark.tsx. -->
|
||||
<path id="cell" d="M32 4 L56 18 V46 L32 60 L8 46 V18 Z" fill="#f0b13f" stroke="#2c1b0f" stroke-width="3.84" stroke-linejoin="round" />
|
||||
<path id="lid" d="M32 13.6 L45.2 21.3 V36.7 L32 44.4 L18.8 36.7 V21.3 Z" fill="#f9d280" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 477 B |
@@ -1,8 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" fill="#0a0a0a" />
|
||||
<g fill="#f5c518">
|
||||
<rect x="156" y="176" width="200" height="52" rx="10" />
|
||||
<rect x="156" y="242" width="200" height="52" rx="10" />
|
||||
<rect x="156" y="308" width="200" height="52" rx="10" />
|
||||
</g>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112.64" fill="#2c1b0f"/><g transform="translate(51.2 51.2) scale(6.4)"><path d="M32 4 L56 18 V46 L32 60 L8 46 V18 Z" fill="#f0b13f" stroke="#2c1b0f" stroke-width="3.84" stroke-linejoin="round" /><path d="M32 13.6 L45.2 21.3 V36.7 L32 44.4 L18.8 36.7 V21.3 Z" fill="#f9d280" /></g></svg>
|
||||
|
Before Width: | Height: | Size: 340 B After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 620 B |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -37,11 +37,28 @@ const {
|
||||
buildAssetsToPrune,
|
||||
} = self.ApiCoreSwRoutes;
|
||||
|
||||
const SHELL_CACHE = "apicore-shell-v4";
|
||||
// v5: new app icons and colour tokens (T5 spec 12.3) reach installed apps.
|
||||
const SHELL_CACHE = "apicore-shell-v5";
|
||||
// v2: v1 could hold answers this version never keeps (exports, downloads).
|
||||
const DATA_CACHE = "apicore-data-v2";
|
||||
const KNOWN_CACHES = [SHELL_CACHE, DATA_CACHE];
|
||||
|
||||
// The manifest icons (src/app/manifest.ts; sw.spec checks that every one of
|
||||
// them is kept): kept with the shell, so an offline start and the shortcuts
|
||||
// show the app's own icons. Not the favicon: Next links it as
|
||||
// /favicon.ico?<hash>, so a cached /favicon.ico would never answer it.
|
||||
const SHELL_ICONS = [
|
||||
"/icon.svg",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
"/icons/maskable-192.png",
|
||||
"/icons/maskable-512.png",
|
||||
"/icons/monochrome-512.png",
|
||||
"/icons/shortcut-inspection-96.png",
|
||||
"/icons/shortcut-varroa-96.png",
|
||||
"/icons/shortcut-today-96.png",
|
||||
];
|
||||
|
||||
// Same budget as every request of the app (lib/http/fetch-with-timeout.ts).
|
||||
const ASSET_TIMEOUT_MS = 4000;
|
||||
const RUNTIME_CONFIG_PATH = "/api/runtime-config";
|
||||
@@ -453,6 +470,7 @@ async function warmShell() {
|
||||
}
|
||||
}
|
||||
await keepRuntimeConfig(cache).catch(() => undefined);
|
||||
await keepIcons(cache);
|
||||
// Only with the full picture of the current build may old assets go.
|
||||
if (allPagesKept) {
|
||||
await pruneOldBuildAssets(cache, [...assets]);
|
||||
@@ -497,6 +515,21 @@ async function keepAsset(cache, asset) {
|
||||
await cache.put(asset, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the icons fresh on every warm-up (their URLs carry no build hash).
|
||||
* A missing icon is skipped; the pages do not depend on it.
|
||||
*/
|
||||
async function keepIcons(cache) {
|
||||
await Promise.allSettled(
|
||||
SHELL_ICONS.map(async (icon) => {
|
||||
const response = await fetch(icon, { cache: "no-cache" });
|
||||
if (isGoodAnswer(response)) {
|
||||
await cache.put(icon, response);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function keepRuntimeConfig(cache) {
|
||||
const response = await fetch(RUNTIME_CONFIG_PATH, { cache: "no-cache" });
|
||||
if (isGoodAnswer(response)) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* T5 colour codemod (spec 4.2 and 10.8 step 3). Pure and idempotent:
|
||||
*
|
||||
* - `text-primary` -> `text-primary-ink` (honey is a fill, the ink is the text colour);
|
||||
* `text-primary-foreground`, `text-primary/NN`, `bg-primary…` stay.
|
||||
* - `text-warning` -> `text-warning-ink`.
|
||||
* - `text-warning-foreground` and `text-condition-x-foreground` -> `-ink`, unless the same line
|
||||
* puts the text on the solid surface (`bg-warning`, `bg-condition-x` without `/NN`): on a tint
|
||||
* or on the card only the ink reaches 4.5:1 in every set.
|
||||
* - `border-primary` -> `border-foreground` where the line, or one of the two lines above it,
|
||||
* names a selection state (checked, pressed, selected); `border-primary/NN` stays.
|
||||
*
|
||||
* CLI: node scripts/codemod-t5-colors.mjs [--check] <file or directory…>
|
||||
* --check lists the files that would change and exits 1 if there are any.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Start of a class token: line start, whitespace, quote, backtick, `{`, `(` or a variant `:`. */
|
||||
const BEFORE = String.raw`(?<=^|[\s"'\`{(:!])`;
|
||||
/** End of a class token: no further name characters, no `/NN` opacity, no `-suffix`. */
|
||||
const AFTER = String.raw`(?![\w/-])`;
|
||||
|
||||
const TONES = String.raw`(?:warning|condition-(?:excellent|good|average|weak|critical))`;
|
||||
const SELECTION_STATE = /checked|pressed|selected/;
|
||||
const SELECTION_CONTEXT_LINES = 2;
|
||||
|
||||
const TEXT_PRIMARY = new RegExp(`${BEFORE}text-primary${AFTER}`, "g");
|
||||
const TEXT_WARNING = new RegExp(`${BEFORE}text-warning${AFTER}`, "g");
|
||||
const TONE_FOREGROUND = new RegExp(`${BEFORE}text-(${TONES})-foreground${AFTER}`, "g");
|
||||
const BORDER_PRIMARY = new RegExp(`${BEFORE}border-primary${AFTER}`, "g");
|
||||
|
||||
function hasSolidSurface(line, tone) {
|
||||
return new RegExp(`${BEFORE}bg-${tone}${AFTER}`).test(line);
|
||||
}
|
||||
|
||||
function transformLine(line, context) {
|
||||
let out = line
|
||||
.replace(TEXT_PRIMARY, "text-primary-ink")
|
||||
.replace(TEXT_WARNING, "text-warning-ink")
|
||||
.replace(TONE_FOREGROUND, (match, tone) => (hasSolidSurface(line, tone) ? match : `text-${tone}-ink`));
|
||||
if (SELECTION_STATE.test(context)) out = out.replace(BORDER_PRIMARY, "border-foreground");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Applies the colour rules to one source text; line endings are kept. */
|
||||
export function transform(source) {
|
||||
const lines = source.split("\n");
|
||||
return lines
|
||||
.map((line, index) => {
|
||||
const context = lines.slice(Math.max(0, index - SELECTION_CONTEXT_LINES), index + 1).join("\n");
|
||||
return transformLine(line, context);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function sourceFiles(path) {
|
||||
if (statSync(path).isDirectory()) {
|
||||
return readdirSync(path).flatMap((name) => sourceFiles(join(path, name)));
|
||||
}
|
||||
return /\.tsx?$/.test(path) && !/\.spec\./.test(path) ? [path] : [];
|
||||
}
|
||||
|
||||
function main(args) {
|
||||
const check = args.includes("--check");
|
||||
const targets = args.filter((arg) => arg !== "--check");
|
||||
if (targets.length === 0) {
|
||||
console.error("usage: node scripts/codemod-t5-colors.mjs [--check] <file or directory…>");
|
||||
return 2;
|
||||
}
|
||||
const changed = targets.flatMap(sourceFiles).filter((file) => {
|
||||
const before = readFileSync(file, "utf8");
|
||||
const after = transform(before);
|
||||
if (after === before) return false;
|
||||
if (!check) writeFileSync(file, after);
|
||||
return true;
|
||||
});
|
||||
for (const file of changed) console.log(file);
|
||||
console.log(`${changed.length} file(s) ${check ? "to migrate" : "migrated"}`);
|
||||
return check && changed.length > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transform } from "./codemod-t5-colors.mjs";
|
||||
|
||||
describe("codemod t5 colours (spec 10.8 step 3)", () => {
|
||||
it("text-primary becomes text-primary-ink", () => {
|
||||
expect(transform('<a className="text-sm text-primary hover:underline">')).toBe(
|
||||
'<a className="text-sm text-primary-ink hover:underline">',
|
||||
);
|
||||
expect(transform('cn("data-active:text-primary", x)')).toBe('cn("data-active:text-primary-ink", x)');
|
||||
});
|
||||
|
||||
it("text-primary-foreground, bg-primary and text-primary/80 stay unchanged", () => {
|
||||
const source = 'className="bg-primary text-primary-foreground text-primary/80 text-primary/[0.07] bg-primary/10"';
|
||||
expect(transform(source)).toBe(source);
|
||||
});
|
||||
|
||||
it("text-warning becomes text-warning-ink but text-warning-foreground stays on a solid warning surface", () => {
|
||||
expect(transform('<AlertTriangle className="size-4 text-warning" />')).toBe(
|
||||
'<AlertTriangle className="size-4 text-warning-ink" />',
|
||||
);
|
||||
const solid = 'className="bg-warning text-warning-foreground"';
|
||||
expect(transform(solid)).toBe(solid);
|
||||
});
|
||||
|
||||
it("warning text on a tint or on the card becomes the ink", () => {
|
||||
expect(transform('"bg-warning/15 text-warning-foreground"')).toBe('"bg-warning/15 text-warning-ink"');
|
||||
expect(transform('<span className="text-warning-foreground">')).toBe('<span className="text-warning-ink">');
|
||||
});
|
||||
|
||||
it("badge classes with text-condition-good-foreground become -ink", () => {
|
||||
expect(transform('GOOD: "border-transparent bg-condition-good/15 text-condition-good-foreground",')).toBe(
|
||||
'GOOD: "border-transparent bg-condition-good/15 text-condition-good-ink",',
|
||||
);
|
||||
expect(transform('<CheckCircle2 className="size-5 text-condition-critical-foreground" />')).toBe(
|
||||
'<CheckCircle2 className="size-5 text-condition-critical-ink" />',
|
||||
);
|
||||
});
|
||||
|
||||
it("text on a solid condition surface keeps its foreground", () => {
|
||||
const solid = '4: "border-condition-good bg-condition-good text-condition-good-foreground",';
|
||||
expect(transform(solid)).toBe(solid);
|
||||
});
|
||||
|
||||
it("border-primary on a selection state becomes border-foreground", () => {
|
||||
expect(transform('"data-checked:border-primary data-checked:bg-primary"')).toBe(
|
||||
'"data-checked:border-foreground data-checked:bg-primary"',
|
||||
);
|
||||
expect(transform('className="has-[:checked]:border-primary has-[:checked]:bg-primary/10"')).toBe(
|
||||
'className="has-[:checked]:border-foreground has-[:checked]:bg-primary/10"',
|
||||
);
|
||||
// The state may sit on the line(s) just above the class string.
|
||||
expect(transform('selected &&\n "border-primary bg-primary font-medium",')).toBe(
|
||||
'selected &&\n "border-foreground bg-primary font-medium",',
|
||||
);
|
||||
});
|
||||
|
||||
it("border-primary without a selection state and translucent borders stay", () => {
|
||||
const plain = 'className={ok ? "rounded-lg border border-primary bg-primary/5" : "border-destructive"}';
|
||||
expect(transform(plain)).toBe(plain);
|
||||
const translucent = 'className={selected ? "border-primary/40" : ""}';
|
||||
expect(transform(translucent)).toBe(translucent);
|
||||
});
|
||||
|
||||
it("keeps CRLF line endings", () => {
|
||||
expect(transform('a\r\nclassName="text-primary"\r\n')).toBe('a\r\nclassName="text-primary-ink"\r\n');
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const source = [
|
||||
'className="text-primary text-warning bg-warning/15 text-warning-foreground"',
|
||||
'checked ? "border-primary bg-primary/10" : "border-border"',
|
||||
'"bg-condition-weak/15 text-condition-weak-foreground"',
|
||||
].join("\n");
|
||||
const once = transform(source);
|
||||
expect(once).not.toBe(source);
|
||||
expect(transform(once)).toBe(once);
|
||||
});
|
||||
|
||||
it("leaves nothing to migrate in src", () => {
|
||||
const files = sourceFiles(join(process.cwd(), "src"));
|
||||
expect(files.length).toBeGreaterThan(100);
|
||||
const pending = files.filter((file) => {
|
||||
const text = readFileSync(file, "utf8");
|
||||
return transform(text) !== text;
|
||||
});
|
||||
expect(pending).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function sourceFiles(dir) {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) return sourceFiles(path);
|
||||
return /\.tsx?$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// App icons from the master mark (spec 12.2): `npm run icons`.
|
||||
// Reads public/brand/apicore-mark.svg and writes the PNG/SVG/ICO files listed
|
||||
// in ICON_FILES. Idempotent: the same input gives byte-identical files, and a
|
||||
// file whose bytes would not change is not rewritten. The output is committed,
|
||||
// so the Docker image never runs this script.
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import sharp from "sharp";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const MASTER = "public/brand/apicore-mark.svg";
|
||||
|
||||
// Spec 12.1: dark ink oklch(0.24 0.035 55) behind the cell, light wax for shortcuts.
|
||||
const INK = "#2c1b0f";
|
||||
const WAX = "#f9d280";
|
||||
const WHITE = "#ffffff";
|
||||
|
||||
// Every variant is drawn on a 512 grid; the 64 grid of the mark is scaled into it.
|
||||
const GRID = 512;
|
||||
const MARK_GRID = 64;
|
||||
// Share of the edge the 64 box of the mark takes. "any": the cell is 70 % high.
|
||||
// Maskable/monochrome: the cell (stroke included) stays inside the safe circle
|
||||
// of 40 % radius and below 66 % of the edge (spec 12.2): 0.68 gives 32 % / 64 %.
|
||||
const MARK_BOX = { any: 0.8, maskable: 0.68, favicon: 1 };
|
||||
// Spec 12.2: "Ecken 22 % gerundet".
|
||||
const ANY_CORNER_RADIUS = 0.22;
|
||||
// Shortcut icons: the 24 px area icon takes half the edge, inside any launcher mask.
|
||||
const SHORTCUT_ICON_SHARE = 0.5;
|
||||
const LUCIDE_GRID = 24;
|
||||
|
||||
/** Shortcut icon per manifest shortcut: the lucide icon of its area (spec 11). */
|
||||
export const SHORTCUT_ICONS = [
|
||||
{ file: "public/icons/shortcut-inspection-96.png", area: "inspections", lucide: "clipboard-check" },
|
||||
{ file: "public/icons/shortcut-varroa-96.png", area: "varroa", lucide: "bug" },
|
||||
{ file: "public/icons/shortcut-today-96.png", area: "dashboard", lucide: "layout-dashboard" },
|
||||
];
|
||||
const SHORTCUT_SIZE = 96;
|
||||
|
||||
export const ICON_FILES = [
|
||||
"public/icon.svg",
|
||||
"public/icons/icon-192.png",
|
||||
"public/icons/icon-512.png",
|
||||
"public/icons/maskable-192.png",
|
||||
"public/icons/maskable-512.png",
|
||||
"public/icons/monochrome-512.png",
|
||||
...SHORTCUT_ICONS.map((shortcut) => shortcut.file),
|
||||
"src/app/apple-icon.png",
|
||||
"src/app/favicon.ico",
|
||||
];
|
||||
|
||||
const FAVICON_SIZES = [16, 32, 48];
|
||||
const PNG_OPTIONS = { compressionLevel: 9, adaptiveFiltering: true, palette: false };
|
||||
|
||||
/**
|
||||
* The `d` of the cell and the lid in the master SVG.
|
||||
* @param {string} svg
|
||||
* @returns {{ cell: string, lid: string }}
|
||||
*/
|
||||
export function readMasterPaths(svg) {
|
||||
return { cell: pathElement(svg, "cell").d, lid: pathElement(svg, "lid").d };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} svg
|
||||
* @param {string} id
|
||||
* @returns {{ element: string, d: string }}
|
||||
*/
|
||||
function pathElement(svg, id) {
|
||||
const element = svg.match(new RegExp(`<path\\b[^>]*\\bid="${id}"[^>]*/>`))?.[0];
|
||||
const d = element?.match(/\bd="([^"]+)"/)?.[1];
|
||||
if (!element || !d) {
|
||||
throw new Error(`${MASTER}: no <path id="${id}" d="…"/>`);
|
||||
}
|
||||
return { element, d };
|
||||
}
|
||||
|
||||
const round = (value) => Number(value.toFixed(4));
|
||||
|
||||
/** The mark scaled into the 512 grid, centred, taking `box` of the edge. */
|
||||
function markGroup(content, box) {
|
||||
const scale = round((box * GRID) / MARK_GRID);
|
||||
const offset = round((GRID - box * GRID) / 2);
|
||||
return `<g transform="translate(${offset} ${offset}) scale(${scale})">${content}</g>`;
|
||||
}
|
||||
|
||||
function svgDocument(body, size) {
|
||||
const dimensions = size ? ` width="${size}" height="${size}"` : "";
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg"${dimensions} viewBox="0 0 ${GRID} ${GRID}">${body}</svg>\n`;
|
||||
}
|
||||
|
||||
/** The SVG bodies of every mark variant, from the master. */
|
||||
function variants(master) {
|
||||
const cell = pathElement(master, "cell");
|
||||
const lid = pathElement(master, "lid");
|
||||
const withoutId = (element) => element.replace(/\sid="[^"]*"/, "");
|
||||
const colouredMark = `${withoutId(cell.element)}${withoutId(lid.element)}`;
|
||||
const silhouette = withoutId(cell.element)
|
||||
.replace(/\bfill="[^"]*"/, `fill="${WHITE}"`)
|
||||
.replace(/\bstroke="[^"]*"/, `stroke="${WHITE}"`);
|
||||
const radius = round(ANY_CORNER_RADIUS * GRID);
|
||||
return {
|
||||
any: `<rect width="${GRID}" height="${GRID}" rx="${radius}" fill="${INK}"/>${markGroup(colouredMark, MARK_BOX.any)}`,
|
||||
apple: `<rect width="${GRID}" height="${GRID}" fill="${INK}"/>${markGroup(colouredMark, MARK_BOX.any)}`,
|
||||
maskable: `<rect width="${GRID}" height="${GRID}" fill="${INK}"/>${markGroup(colouredMark, MARK_BOX.maskable)}`,
|
||||
monochrome: markGroup(silhouette, MARK_BOX.maskable),
|
||||
favicon: markGroup(colouredMark, MARK_BOX.favicon),
|
||||
};
|
||||
}
|
||||
|
||||
/** Lucide icon node (`[tag, attrs][]`) of an icon name, from the installed lucide-react. */
|
||||
async function lucideNode(name) {
|
||||
const icon = await import(`lucide-react/dist/esm/icons/${name}.mjs`);
|
||||
return icon.__iconNode;
|
||||
}
|
||||
|
||||
function lucideMarkup(node) {
|
||||
return node
|
||||
.map(([tag, attrs]) => {
|
||||
const attributes = Object.entries(attrs)
|
||||
.filter(([key]) => key !== "key")
|
||||
.map(([key, value]) => `${key}="${value}"`)
|
||||
.join(" ");
|
||||
return `<${tag} ${attributes}/>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Area icon in ink on wax, full bleed (spec 12.2). */
|
||||
function shortcutSvg(markup) {
|
||||
const scale = round((SHORTCUT_ICON_SHARE * GRID) / LUCIDE_GRID);
|
||||
const offset = round((GRID - SHORTCUT_ICON_SHARE * GRID) / 2);
|
||||
return (
|
||||
`<rect width="${GRID}" height="${GRID}" fill="${WAX}"/>` +
|
||||
`<g transform="translate(${offset} ${offset}) scale(${scale})" fill="none" stroke="${INK}" ` +
|
||||
`stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${markup}</g>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderPng(body, size) {
|
||||
return sharp(Buffer.from(svgDocument(body, size))).png(PNG_OPTIONS).toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* ICO container with PNG entries (sharp writes no ICO): ICONDIR, one
|
||||
* ICONDIRENTRY per image, then the PNG data.
|
||||
* @param {{ size: number, png: Buffer }[]} images
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function buildIco(images) {
|
||||
const HEADER = 6;
|
||||
const ENTRY = 16;
|
||||
const header = Buffer.alloc(HEADER);
|
||||
header.writeUInt16LE(0, 0);
|
||||
header.writeUInt16LE(1, 2);
|
||||
header.writeUInt16LE(images.length, 4);
|
||||
let offset = HEADER + ENTRY * images.length;
|
||||
const entries = images.map(({ size, png }) => {
|
||||
const entry = Buffer.alloc(ENTRY);
|
||||
// One byte each: 256 is written as 0.
|
||||
entry.writeUInt8(size >= 256 ? 0 : size, 0);
|
||||
entry.writeUInt8(size >= 256 ? 0 : size, 1);
|
||||
entry.writeUInt8(0, 2);
|
||||
entry.writeUInt8(0, 3);
|
||||
entry.writeUInt16LE(1, 4);
|
||||
entry.writeUInt16LE(32, 6);
|
||||
entry.writeUInt32LE(png.length, 8);
|
||||
entry.writeUInt32LE(offset, 12);
|
||||
offset += png.length;
|
||||
return entry;
|
||||
});
|
||||
return Buffer.concat([header, ...entries, ...images.map((image) => image.png)]);
|
||||
}
|
||||
|
||||
function writeIfChanged(file, content) {
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
let current = null;
|
||||
try {
|
||||
current = readFileSync(file);
|
||||
} catch {
|
||||
// Not there yet.
|
||||
}
|
||||
if (!current || !current.equals(content)) {
|
||||
writeFileSync(file, content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders every icon into `outRoot` (same relative paths as in the app).
|
||||
* @param {{ root?: string, outRoot?: string }} [options]
|
||||
* @returns {Promise<string[]>} the files, relative to `outRoot`
|
||||
*/
|
||||
export async function generateIcons({ root = ROOT, outRoot = root } = {}) {
|
||||
const master = readFileSync(path.join(root, MASTER), "utf8");
|
||||
const bodies = variants(master);
|
||||
const outputs = new Map();
|
||||
|
||||
outputs.set("public/icon.svg", Buffer.from(svgDocument(bodies.any)));
|
||||
for (const size of [192, 512]) {
|
||||
outputs.set(`public/icons/icon-${size}.png`, await renderPng(bodies.any, size));
|
||||
outputs.set(`public/icons/maskable-${size}.png`, await renderPng(bodies.maskable, size));
|
||||
}
|
||||
outputs.set("public/icons/monochrome-512.png", await renderPng(bodies.monochrome, 512));
|
||||
for (const shortcut of SHORTCUT_ICONS) {
|
||||
const markup = lucideMarkup(await lucideNode(shortcut.lucide));
|
||||
outputs.set(shortcut.file, await renderPng(shortcutSvg(markup), SHORTCUT_SIZE));
|
||||
}
|
||||
outputs.set("src/app/apple-icon.png", await renderPng(bodies.apple, 180));
|
||||
const favicons = [];
|
||||
for (const size of FAVICON_SIZES) {
|
||||
favicons.push({ size, png: await renderPng(bodies.favicon, size) });
|
||||
}
|
||||
outputs.set("src/app/favicon.ico", buildIco(favicons));
|
||||
|
||||
const files = ICON_FILES.filter((file) => outputs.has(file));
|
||||
for (const file of files) {
|
||||
writeIfChanged(path.join(outRoot, ...file.split("/")), outputs.get(file));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isCli) {
|
||||
generateIcons()
|
||||
.then((files) => {
|
||||
process.stdout.write(`${files.join("\n")}\n`);
|
||||
})
|
||||
.catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BRAND_CELL_PATHS } from "@/components/brand-mark";
|
||||
import { AREAS } from "@/lib/areas";
|
||||
import { ICON_FILES, SHORTCUT_ICONS, buildIco, generateIcons, readMasterPaths } from "./generate-icons.mjs";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
describe("generate-icons (spec 12.2)", () => {
|
||||
let outDir: string;
|
||||
beforeEach(() => {
|
||||
outDir = mkdtempSync(path.join(tmpdir(), "icons-"));
|
||||
});
|
||||
afterEach(() => rmSync(outDir, { recursive: true, force: true }));
|
||||
|
||||
it("the master SVG has the BrandMark geometry", () => {
|
||||
expect(readMasterPaths(readFileSync(path.join(ROOT, "public/brand/apicore-mark.svg"), "utf8"))).toEqual(
|
||||
BRAND_CELL_PATHS,
|
||||
);
|
||||
});
|
||||
|
||||
it("the lid sits centred over the cell, only lifted", () => {
|
||||
const xs = (d: string) => [...d.matchAll(/L([\d.]+)\s/g)].map((match) => Number(match[1]));
|
||||
const lidXs = xs(BRAND_CELL_PATHS.lid);
|
||||
expect(Math.min(...lidXs) + Math.max(...lidXs)).toBeCloseTo(64, 5);
|
||||
});
|
||||
|
||||
it("shortcut icons use the icon of their area", () => {
|
||||
const pascal = (name: string) => name.replace(/(^|-)(\w)/g, (_, __, char: string) => char.toUpperCase());
|
||||
for (const shortcut of SHORTCUT_ICONS) {
|
||||
expect(AREAS[shortcut.area as keyof typeof AREAS].icon.displayName, shortcut.file).toBe(pascal(shortcut.lucide));
|
||||
}
|
||||
});
|
||||
|
||||
it("running the icon script twice leaves the files byte-identical", async () => {
|
||||
const first = await generateIcons({ root: ROOT, outRoot: outDir });
|
||||
const snapshot = new Map(first.map((file) => [file, readFileSync(path.join(outDir, file))]));
|
||||
const second = await generateIcons({ root: ROOT, outRoot: outDir });
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect([...first].sort()).toEqual([...ICON_FILES].sort());
|
||||
for (const file of second) {
|
||||
expect(readFileSync(path.join(outDir, file)).equals(snapshot.get(file) as Buffer), file).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("buildIco writes a header, one entry per PNG and the PNGs behind it", () => {
|
||||
const png = (size: number) => Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47]), Buffer.alloc(size)]);
|
||||
const ico = buildIco([
|
||||
{ size: 16, png: png(10) },
|
||||
{ size: 256, png: png(20) },
|
||||
]);
|
||||
|
||||
expect([ico.readUInt16LE(0), ico.readUInt16LE(2), ico.readUInt16LE(4)]).toEqual([0, 1, 2]);
|
||||
// 256 px is written as 0 in the one-byte width and height fields.
|
||||
expect([ico.readUInt8(6), ico.readUInt8(22)]).toEqual([16, 0]);
|
||||
expect(ico.readUInt16LE(6 + 6)).toBe(32);
|
||||
const secondOffset = ico.readUInt32LE(22 + 12);
|
||||
expect(ico.readUInt32LE(6 + 12)).toBe(6 + 2 * 16);
|
||||
expect(secondOffset).toBe(6 + 2 * 16 + 14);
|
||||
expect(ico.length).toBe(secondOffset + 24);
|
||||
});
|
||||
});
|
||||
@@ -126,8 +126,8 @@ export function WeatherCard({
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border p-2.5 text-sm ${
|
||||
todaySuitability
|
||||
? "border-primary/30 bg-primary/5 text-primary"
|
||||
: "border-warning/40 bg-warning/10 text-warning-foreground"
|
||||
? "border-primary/30 bg-primary/5 text-primary-ink"
|
||||
: "border-warning/40 bg-warning/10 text-warning-ink"
|
||||
}`}
|
||||
>
|
||||
{todaySuitability ? (
|
||||
@@ -155,7 +155,7 @@ export function WeatherCard({
|
||||
{day.tempMinC !== null ? `${Math.round(day.tempMinC)}°` : "–"}
|
||||
</span>
|
||||
{day.precipitationMm !== null && day.precipitationMm > 0 ? (
|
||||
<span className="text-xs text-primary">
|
||||
<span className="text-xs text-primary-ink">
|
||||
{day.precipitationMm.toFixed(0)} mm
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -26,7 +26,7 @@ const PRINT_DONE = "Etiketten-PDF heruntergeladen.";
|
||||
const PROOF_DONE = "Probedruck (MUSTER) heruntergeladen. Er ist nicht zum Verkauf gedacht.";
|
||||
const CHECK_FALLBACK = "Die Pflichtangaben konnten nicht geprüft werden.";
|
||||
const LINK_CLASS =
|
||||
"inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-ring";
|
||||
"inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary-ink underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-ring";
|
||||
|
||||
type FixTarget = "master" | "product" | "designer";
|
||||
|
||||
@@ -301,7 +301,7 @@ export function PrintLabelsCard({
|
||||
<ul className="flex flex-col gap-1">
|
||||
{warnings.map((warning, index) => (
|
||||
<li key={`${warning.code}-${index}`} className="flex items-start gap-2 text-sm">
|
||||
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning-ink" />
|
||||
<span>
|
||||
<span className="font-medium">Hinweis:</span> {warning.title}
|
||||
</span>
|
||||
|
||||
@@ -452,14 +452,14 @@ export default function NewBottlingBatchPage() {
|
||||
/>
|
||||
{withdrawalCheckFailed ? (
|
||||
<p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||
Wartezeit konnte nicht geprüft werden
|
||||
</p>
|
||||
) : null}
|
||||
{withdrawalHints.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5 rounded-lg bg-warning/15 p-3 text-sm">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden />
|
||||
<AlertTriangle className="size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||
Honig aus einer Wartezeit
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1">
|
||||
|
||||
@@ -28,7 +28,7 @@ export function BottomNav({ user }: { user: Pick<AuthUser, "permissions"> }) {
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-1 text-xs",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
isActive ? "text-primary-ink" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" aria-hidden="true" />
|
||||
|
||||
@@ -355,7 +355,7 @@ export default function CalendarPage() {
|
||||
className={cn(
|
||||
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0",
|
||||
isCurrentMonth ? "bg-card" : "bg-muted/40",
|
||||
isSelected && "outline-2 -outline-offset-2 outline-primary",
|
||||
isSelected && "outline-2 -outline-offset-2 outline-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -209,7 +209,7 @@ function VarroaTrendChart({
|
||||
<div className="flex flex-col gap-2">
|
||||
<svg
|
||||
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
||||
className="w-full text-primary"
|
||||
className="w-full text-primary-ink"
|
||||
role="img"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descId}
|
||||
@@ -293,7 +293,7 @@ function ChartLegend({ hasRed, hasYellow }: { hasRed: boolean; hasYellow: boolea
|
||||
return (
|
||||
<ul aria-label="Legende" className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="text-primary">
|
||||
<span className="text-primary-ink">
|
||||
<LegendSwatch width={2} />
|
||||
</span>
|
||||
<span>Milbenfall pro Tag</span>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createColony } from "@/lib/colonies/api";
|
||||
import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
|
||||
@@ -33,7 +33,7 @@ function Step({ number, title, titleId, state, children }: StepProps) {
|
||||
return (
|
||||
<li
|
||||
aria-current={state === "current" ? "step" : undefined}
|
||||
className="flex flex-col gap-1 rounded-lg border border-border/60 p-3 aria-[current=step]:border-primary aria-[current=step]:bg-primary/10"
|
||||
className="flex flex-col gap-1 rounded-lg border border-border/60 p-3 aria-[current=step]:border-foreground aria-[current=step]:bg-primary/10"
|
||||
>
|
||||
<span className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span
|
||||
|
||||
@@ -136,7 +136,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
|
||||
{NOTICE_KINDS.map((entry) => (
|
||||
<label
|
||||
key={entry}
|
||||
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border border-border px-3 py-2 has-[:checked]:border-primary has-[:checked]:bg-primary/10"
|
||||
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border border-border px-3 py-2 has-[:checked]:border-foreground has-[:checked]:bg-primary/10"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
|
||||
@@ -17,7 +17,7 @@ const STATE_ICON: Record<ReadinessItemState, LucideIcon> = {
|
||||
};
|
||||
|
||||
const FIX_LINK_CLASS =
|
||||
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary-ink underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
|
||||
function FixLink({ itemKey, subject, context }: { itemKey: string; subject: ReadinessSubject | null; context: string }) {
|
||||
const { href, label, available } = fixLinkFor(itemKey, subject);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
||||
import { DetailHeader } from "@/components/detail-header";
|
||||
import { StandNote } from "@/components/offline/stand-note";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
||||
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
|
||||
|
||||
@@ -219,7 +219,7 @@ export default function DashboardPage() {
|
||||
</span>
|
||||
{href ? (
|
||||
<Link
|
||||
className="inline-flex min-h-11 shrink-0 items-center rounded-lg px-2 font-medium text-primary underline-offset-4 hover:underline"
|
||||
className="inline-flex min-h-11 shrink-0 items-center rounded-lg px-2 font-medium text-primary-ink underline-offset-4 hover:underline"
|
||||
href={href}
|
||||
>
|
||||
Ansehen
|
||||
@@ -247,7 +247,7 @@ export default function DashboardPage() {
|
||||
className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
<Icon
|
||||
className={`size-5 shrink-0 ${tile.attention ? "text-warning" : "text-primary"}`}
|
||||
className={`size-5 shrink-0 ${tile.attention ? "text-warning-ink" : "text-primary-ink"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="font-heading text-lg font-semibold tabular-nums">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createEquipment } from "@/lib/equipment/api";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchExtraction } from "@/lib/extractions/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { fetchHarvests } from "@/lib/harvests/api";
|
||||
import {
|
||||
createExtraction,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createFeeding } from "@/lib/feedings/api";
|
||||
import { createFeedingsBulk } from "@/lib/bulk/api";
|
||||
import {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchHarvest } from "@/lib/harvests/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function NewHarvestPage() {
|
||||
</Field>
|
||||
{withdrawalQuery.isError ? (
|
||||
<p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||
Wartezeiten konnten nicht geprüft werden. Vor der Ernte die Behandlungen der
|
||||
Völker selbst ansehen.
|
||||
</p>
|
||||
|
||||
@@ -114,7 +114,7 @@ function MovementList({ movements }: { movements: HoneyBatchMovement[] }) {
|
||||
className={
|
||||
movement.quantityGrams < 0
|
||||
? "font-semibold tabular-nums text-destructive"
|
||||
: "font-semibold tabular-nums text-primary"
|
||||
: "font-semibold tabular-nums text-primary-ink"
|
||||
}
|
||||
>
|
||||
{formatSignedKg(gramsToKg(movement.quantityGrams))}
|
||||
|
||||
@@ -98,7 +98,7 @@ function HiveOpsList({ ops, base, catalog, onChange, onUndone, onEmptied }: Hive
|
||||
</div>
|
||||
{blocked?.index === index ? (
|
||||
<p role="alert" className="flex items-start gap-2 text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||
{blocked.reason} Mach zuerst die späteren Änderungen rückgängig.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -734,7 +734,7 @@ function InspectionCaptureForm({
|
||||
{noQueenSigns ? (
|
||||
<p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
|
||||
<AlertTriangle
|
||||
className="mt-0.5 size-4 shrink-0 text-warning"
|
||||
className="mt-0.5 size-4 shrink-0 text-warning-ink"
|
||||
aria-hidden
|
||||
/>
|
||||
Weder Königin noch Stifte gesehen. Nach dem Speichern wird
|
||||
@@ -894,7 +894,7 @@ function InspectionCaptureForm({
|
||||
/>
|
||||
{values.feedLegacyText !== null ? (
|
||||
<p role="status" className="flex items-start gap-2 text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||
{feedLegacyHint(values.feedLegacyText)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { buildBook } from "@/lib/medicines/book";
|
||||
|
||||
@@ -37,7 +37,7 @@ export function MoreSheet({ user }: { user: Pick<AuthUser, "permissions"> }) {
|
||||
<SheetTrigger
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-1 text-xs",
|
||||
isMoreActive ? "text-primary" : "text-muted-foreground",
|
||||
isMoreActive ? "text-primary-ink" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Menu className="size-5" aria-hidden="true" />
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import {
|
||||
OFFLINE_PACKS_QUERY_KEY,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { FormEvent, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { createProductionBatch } from "@/lib/production-batches/api";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchProduct } from "@/lib/products/api";
|
||||
import { fetchProductionBatches } from "@/lib/production-batches/api";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createProduct } from "@/lib/products/api";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { mockMedia, resetDocument } from "@/lib/appearance/test-helpers";
|
||||
@@ -30,21 +30,20 @@ describe("AppearanceSection", () => {
|
||||
expect(screen.getByText("Gilt nur für dieses Gerät.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Dunkel and Wie Gerät without the dev flag", () => {
|
||||
it("starts on Hell and offers Dunkel and Wie Gerät without a flag", () => {
|
||||
render(<AppearanceSection />);
|
||||
expect(screen.getByRole("radio", { name: /^Hell/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("radio", { name: /^Dunkel/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("radio", { name: /^Wie Gerät/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: /^Hell/ })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: /^Dunkel/ })).not.toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: /^Wie Gerät/ })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("offers Dunkel and Wie Gerät with the dev flag", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
|
||||
it("dark is selectable without a flag", async () => {
|
||||
render(<AppearanceSection />);
|
||||
|
||||
await userEvent.click(screen.getByRole("radio", { name: /^Dunkel/ }));
|
||||
|
||||
expect(document.documentElement).toHaveClass("dark");
|
||||
expect(screen.getByRole("radio", { name: /^Wie Gerät/ })).toBeInTheDocument();
|
||||
expect(window.localStorage.getItem("apicore.theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("changes the motion level to Dezent and applies it", async () => {
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
import { useId } from "react";
|
||||
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
isDarkEnabled,
|
||||
useAppearance,
|
||||
type ContrastChoice,
|
||||
type ThemeChoice,
|
||||
} from "@/lib/appearance/appearance";
|
||||
import { useAppearance, type ContrastChoice, type ThemeChoice } from "@/lib/appearance/appearance";
|
||||
import { MOTION_LEVELS, type MotionLevel } from "@/lib/motion/motion-preference";
|
||||
|
||||
const OPTION_CLASS = "h-auto justify-start px-3 py-2 text-left";
|
||||
@@ -38,8 +33,6 @@ const MOTION_OPTIONS: readonly ChoiceOption<MotionLevel>[] = MOTION_LEVELS.map((
|
||||
export function AppearanceSection() {
|
||||
const { theme, contrast, motion, setTheme, setContrast, setMotion } = useAppearance();
|
||||
const ids = { theme: useId(), contrast: useId(), motion: useId(), motionHint: useId() };
|
||||
// Dark has unreadable old values until task A4; the dev flag shows it for work on A4.
|
||||
const themeOptions = isDarkEnabled() ? THEME_OPTIONS : THEME_OPTIONS.slice(0, 1);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -56,7 +49,7 @@ export function AppearanceSection() {
|
||||
Farbschema
|
||||
</p>
|
||||
<ChoiceGroup
|
||||
options={themeOptions}
|
||||
options={THEME_OPTIONS}
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
labelledBy={ids.theme}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ChangeEvent, FormEvent, useRef, useState } from "react";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { fetchQueen } from "@/lib/queens/api";
|
||||
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
|
||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createQueen, fetchQueens } from "@/lib/queens/api";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { createRawMaterialLot } from "@/lib/raw-materials/api";
|
||||
|
||||
@@ -53,8 +53,8 @@ export function ApiKeyAllowlist({ apiKey }: { apiKey: ApiKey }) {
|
||||
Nur von: <span className="font-mono">{apiKey.allowedIps.join(", ")}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-warning-foreground">
|
||||
<ShieldAlert className="size-3.5 text-warning" aria-hidden />
|
||||
<span className="flex items-center gap-1 text-warning-ink">
|
||||
<ShieldAlert className="size-3.5 text-warning-ink" aria-hidden />
|
||||
Von jeder Adresse nutzbar – besser auf den Server von Wabenhain beschränken.
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -155,7 +155,7 @@ export function ApiKeysTab() {
|
||||
className="flex flex-col gap-3 rounded-2xl border border-primary/40 bg-primary/5 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<ShieldCheck className="size-5 text-primary" aria-hidden />
|
||||
<ShieldCheck className="size-5 text-primary-ink" aria-hidden />
|
||||
Schlüssel „{created.name}“ angelegt
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { TabSwitcher } from "@/components/tab-switcher";
|
||||
import { WebhooksTab } from "./webhooks-tab";
|
||||
|
||||
@@ -133,7 +133,7 @@ function ElementBox({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute cursor-move touch-none select-none",
|
||||
selected && "outline outline-2 outline-primary",
|
||||
selected && "outline outline-2 outline-foreground",
|
||||
)}
|
||||
style={{
|
||||
left: box.left,
|
||||
@@ -146,7 +146,7 @@ function ElementBox({
|
||||
{renderContent(element, scale)}
|
||||
{selected ? (
|
||||
<div
|
||||
className="absolute right-0 bottom-0 size-3 translate-x-1/2 translate-y-1/2 cursor-nwse-resize rounded-full border-2 border-primary bg-background"
|
||||
className="absolute right-0 bottom-0 size-3 translate-x-1/2 translate-y-1/2 cursor-nwse-resize rounded-full border-2 border-foreground bg-background"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
onStartResize(event);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import { createLabelTemplate } from "@/lib/label-templates/api";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { TabSwitcher } from "@/components/tab-switcher";
|
||||
import { UsersTab } from "./users-tab";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createTask } from "@/lib/tasks/api";
|
||||
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
||||
import { fetchUsers } from "@/lib/users/api";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { saveErrorText } from "@/lib/honey-batches/errors";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -1,42 +0,0 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
// iOS's "Add to Home Screen" doesn't read the web manifest at all - it needs
|
||||
// its own <link rel="apple-touch-icon">, which only exists if this special
|
||||
// file (or a literal apple-icon.png) is present. Without it, iOS falls back
|
||||
// to a screenshot of whatever page was open, not the app's brand mark.
|
||||
// Mirrors public/icon.svg's existing design (used in the manifest for
|
||||
// Android/Chrome) as plain positioned divs rather than inline SVG paths,
|
||||
// since that renders reliably through Satori (next/og's renderer). iOS
|
||||
// applies its own rounded-square mask on top, so this stays a plain square.
|
||||
export const size = { width: 180, height: 180 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default function AppleIcon() {
|
||||
const barStyle = {
|
||||
position: "absolute" as const,
|
||||
left: "30%",
|
||||
width: "40%",
|
||||
height: "10%",
|
||||
borderRadius: 6,
|
||||
background: "#f5c518",
|
||||
};
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
background: "#0a0a0a",
|
||||
}}
|
||||
>
|
||||
<div style={{ ...barStyle, top: "34%" }} />
|
||||
<div style={{ ...barStyle, top: "47%" }} />
|
||||
<div style={{ ...barStyle, top: "60%" }} />
|
||||
</div>
|
||||
),
|
||||
{ ...size },
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -102,55 +102,52 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.955 0.017 80);
|
||||
--foreground: oklch(0.22 0.028 55);
|
||||
--background: oklch(0.968 0.014 85);
|
||||
--foreground: oklch(0.21 0.03 55);
|
||||
--card: oklch(0.995 0.004 90);
|
||||
--card-foreground: oklch(0.22 0.028 55);
|
||||
--card-foreground: oklch(0.21 0.03 55);
|
||||
--popover: oklch(0.995 0.004 90);
|
||||
--popover-foreground: oklch(0.22 0.028 55);
|
||||
--primary: oklch(0.6 0.15 48);
|
||||
--primary-foreground: oklch(0.99 0.02 85);
|
||||
--popover-foreground: oklch(0.21 0.03 55);
|
||||
--primary: oklch(0.8 0.145 78);
|
||||
--primary-foreground: oklch(0.21 0.035 55);
|
||||
--secondary: oklch(0.44 0.07 145);
|
||||
--secondary-foreground: oklch(0.98 0.015 100);
|
||||
--muted: oklch(0.9 0.02 82);
|
||||
--muted-foreground: oklch(0.48 0.032 65);
|
||||
--accent: oklch(0.93 0.032 74);
|
||||
--accent-foreground: oklch(0.26 0.045 50);
|
||||
--destructive: oklch(0.5 0.16 30);
|
||||
--border: oklch(0.85 0.024 78);
|
||||
--input: oklch(0.85 0.024 78);
|
||||
--ring: oklch(0.6 0.15 48);
|
||||
--warning: oklch(0.68 0.14 75);
|
||||
--warning-foreground: oklch(0.24 0.04 60);
|
||||
--success: oklch(0.46 0.06 145);
|
||||
--muted: oklch(0.925 0.018 82);
|
||||
--muted-foreground: oklch(0.44 0.03 60);
|
||||
--accent: oklch(0.935 0.03 78);
|
||||
--accent-foreground: oklch(0.25 0.045 50);
|
||||
--destructive: oklch(0.5 0.17 28);
|
||||
--border: oklch(0.86 0.022 78);
|
||||
--input: oklch(0.6 0.035 65);
|
||||
--ring: oklch(0.47 0.115 55);
|
||||
--warning: oklch(0.79 0.15 80);
|
||||
--warning-foreground: oklch(0.23 0.04 60);
|
||||
--success: oklch(0.46 0.08 145);
|
||||
--success-foreground: oklch(0.98 0.015 100);
|
||||
--condition-excellent: oklch(0.58 0.13 145);
|
||||
--condition-excellent-foreground: oklch(0.16 0.04 145);
|
||||
--condition-good: oklch(0.64 0.13 118);
|
||||
--condition-good-foreground: oklch(0.26 0.05 118);
|
||||
--condition-average: oklch(0.7 0.14 90);
|
||||
--condition-average-foreground: oklch(0.28 0.05 90);
|
||||
--condition-weak: oklch(0.68 0.15 55);
|
||||
--condition-weak-foreground: oklch(0.26 0.05 55);
|
||||
--condition-critical: oklch(0.64 0.16 30);
|
||||
--condition-excellent: oklch(0.62 0.13 150);
|
||||
--condition-excellent-foreground: oklch(0.15 0.04 150);
|
||||
--condition-good: oklch(0.645 0.13 125);
|
||||
--condition-good-foreground: oklch(0.17 0.04 125);
|
||||
--condition-average: oklch(0.665 0.125 88);
|
||||
--condition-average-foreground: oklch(0.19 0.04 90);
|
||||
--condition-weak: oklch(0.665 0.14 58);
|
||||
--condition-weak-foreground: oklch(0.18 0.04 55);
|
||||
--condition-critical: oklch(0.62 0.17 30);
|
||||
--condition-critical-foreground: oklch(0.15 0.04 30);
|
||||
--chart-1: oklch(0.6 0.135 50);
|
||||
--chart-2: oklch(0.46 0.06 145);
|
||||
--chart-3: oklch(0.68 0.14 75);
|
||||
--chart-4: oklch(0.5 0.16 30);
|
||||
--chart-5: oklch(0.55 0.05 250);
|
||||
--chart-1: oklch(0.56 0.13 55);
|
||||
--chart-2: oklch(0.46 0.08 145);
|
||||
--chart-3: oklch(0.6 0.12 85);
|
||||
--chart-4: oklch(0.5 0.17 28);
|
||||
--chart-5: oklch(0.52 0.08 250);
|
||||
--radius: 0.75rem;
|
||||
--sidebar: oklch(0.995 0.004 90);
|
||||
--sidebar-foreground: oklch(0.22 0.028 55);
|
||||
--sidebar-primary: oklch(0.6 0.15 48);
|
||||
--sidebar-primary-foreground: oklch(0.99 0.02 85);
|
||||
--sidebar-accent: oklch(0.93 0.032 74);
|
||||
--sidebar-accent-foreground: oklch(0.26 0.045 50);
|
||||
--sidebar-border: oklch(0.85 0.024 78);
|
||||
--sidebar-ring: oklch(0.6 0.15 48);
|
||||
--gradient-honey: linear-gradient(135deg, oklch(0.74 0.15 62) 0%, oklch(0.56 0.16 38) 100%);
|
||||
--gradient-sage: linear-gradient(135deg, oklch(0.64 0.09 150) 0%, oklch(0.4 0.075 145) 100%);
|
||||
--gradient-sky: linear-gradient(135deg, oklch(0.68 0.1 235) 0%, oklch(0.5 0.1 255) 100%);
|
||||
--sidebar-foreground: oklch(0.21 0.03 55);
|
||||
--sidebar-primary: oklch(0.8 0.145 78);
|
||||
--sidebar-primary-foreground: oklch(0.21 0.035 55);
|
||||
--sidebar-accent: oklch(0.935 0.03 78);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.045 50);
|
||||
--sidebar-border: oklch(0.86 0.022 78);
|
||||
--sidebar-ring: oklch(0.47 0.115 55);
|
||||
--shadow-elevated: 0 1px 2px -1px oklch(0.3 0.05 50 / 8%), 0 8px 24px -8px oklch(0.3 0.05 50 / 16%);
|
||||
--shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%);
|
||||
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
|
||||
@@ -160,7 +157,7 @@
|
||||
--holo-fill: oklch(0.78 0.14 220 / 10%);
|
||||
--holo-label-bg: oklch(0.26 0.04 250);
|
||||
--holo-label-fg: oklch(0.95 0.02 230);
|
||||
/* T5 tokens, end values (spec 4.2); the old values above switch in task A4. */
|
||||
/* T5 tokens (spec 4.2). */
|
||||
--primary-ink: oklch(0.47 0.115 55);
|
||||
--warning-ink: oklch(0.5 0.11 65);
|
||||
--destructive-foreground: oklch(0.985 0.005 30);
|
||||
@@ -188,54 +185,51 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.2 0.018 55);
|
||||
--foreground: oklch(0.94 0.02 80);
|
||||
--card: oklch(0.25 0.02 55);
|
||||
--card-foreground: oklch(0.94 0.02 80);
|
||||
--popover: oklch(0.25 0.02 55);
|
||||
--popover-foreground: oklch(0.94 0.02 80);
|
||||
--primary: oklch(0.72 0.13 55);
|
||||
--primary-foreground: oklch(0.2 0.02 55);
|
||||
--secondary: oklch(0.62 0.075 145);
|
||||
--secondary-foreground: oklch(0.15 0.02 145);
|
||||
--muted: oklch(0.29 0.018 55);
|
||||
--muted-foreground: oklch(0.7 0.025 75);
|
||||
--accent: oklch(0.32 0.03 60);
|
||||
--accent-foreground: oklch(0.94 0.02 80);
|
||||
--destructive: oklch(0.62 0.16 30);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 16%);
|
||||
--ring: oklch(0.72 0.13 55);
|
||||
--warning: oklch(0.74 0.13 75);
|
||||
--background: oklch(0.19 0.016 55);
|
||||
--foreground: oklch(0.95 0.018 80);
|
||||
--card: oklch(0.235 0.018 55);
|
||||
--card-foreground: oklch(0.95 0.018 80);
|
||||
--popover: oklch(0.235 0.018 55);
|
||||
--popover-foreground: oklch(0.95 0.018 80);
|
||||
--primary: oklch(0.8 0.14 78);
|
||||
--primary-foreground: oklch(0.19 0.03 55);
|
||||
--secondary: oklch(0.66 0.08 145);
|
||||
--secondary-foreground: oklch(0.16 0.02 145);
|
||||
--muted: oklch(0.28 0.018 55);
|
||||
--muted-foreground: oklch(0.76 0.025 75);
|
||||
--accent: oklch(0.31 0.03 60);
|
||||
--accent-foreground: oklch(0.95 0.018 80);
|
||||
--destructive: oklch(0.7 0.15 28);
|
||||
--border: oklch(0.34 0.02 60);
|
||||
--input: oklch(0.56 0.025 70);
|
||||
--ring: oklch(0.82 0.13 80);
|
||||
--warning: oklch(0.8 0.14 80);
|
||||
--warning-foreground: oklch(0.2 0.03 60);
|
||||
--success: oklch(0.62 0.075 145);
|
||||
--success-foreground: oklch(0.15 0.02 145);
|
||||
--condition-excellent: oklch(0.66 0.11 145);
|
||||
--condition-excellent-foreground: oklch(0.2 0.04 145);
|
||||
--condition-good: oklch(0.72 0.12 118);
|
||||
--condition-good-foreground: oklch(0.22 0.04 118);
|
||||
--condition-average: oklch(0.76 0.13 90);
|
||||
--condition-average-foreground: oklch(0.24 0.04 90);
|
||||
--condition-weak: oklch(0.74 0.13 55);
|
||||
--condition-weak-foreground: oklch(0.22 0.04 55);
|
||||
--condition-critical: oklch(0.62 0.16 30);
|
||||
--condition-critical-foreground: oklch(0.2 0.04 30);
|
||||
--chart-1: oklch(0.72 0.13 55);
|
||||
--chart-2: oklch(0.62 0.075 145);
|
||||
--chart-3: oklch(0.74 0.13 75);
|
||||
--chart-4: oklch(0.62 0.16 30);
|
||||
--chart-5: oklch(0.65 0.06 250);
|
||||
--sidebar: oklch(0.22 0.02 55);
|
||||
--sidebar-foreground: oklch(0.94 0.02 80);
|
||||
--sidebar-primary: oklch(0.72 0.13 55);
|
||||
--sidebar-primary-foreground: oklch(0.2 0.02 55);
|
||||
--sidebar-accent: oklch(0.32 0.03 60);
|
||||
--sidebar-accent-foreground: oklch(0.94 0.02 80);
|
||||
--sidebar-border: oklch(1 0 0 / 12%);
|
||||
--sidebar-ring: oklch(0.72 0.13 55);
|
||||
--gradient-honey: linear-gradient(135deg, oklch(0.62 0.14 55) 0%, oklch(0.38 0.13 40) 100%);
|
||||
--gradient-sage: linear-gradient(135deg, oklch(0.5 0.08 148) 0%, oklch(0.3 0.06 145) 100%);
|
||||
--gradient-sky: linear-gradient(135deg, oklch(0.55 0.09 235) 0%, oklch(0.34 0.09 255) 100%);
|
||||
--success: oklch(0.7 0.09 145);
|
||||
--success-foreground: oklch(0.16 0.02 145);
|
||||
--condition-excellent: oklch(0.68 0.12 150);
|
||||
--condition-excellent-foreground: oklch(0.17 0.04 150);
|
||||
--condition-good: oklch(0.72 0.12 125);
|
||||
--condition-good-foreground: oklch(0.18 0.04 125);
|
||||
--condition-average: oklch(0.77 0.13 90);
|
||||
--condition-average-foreground: oklch(0.2 0.04 90);
|
||||
--condition-weak: oklch(0.74 0.13 60);
|
||||
--condition-weak-foreground: oklch(0.19 0.04 55);
|
||||
--condition-critical: oklch(0.68 0.16 28);
|
||||
--condition-critical-foreground: oklch(0.16 0.04 30);
|
||||
--chart-1: oklch(0.78 0.13 70);
|
||||
--chart-2: oklch(0.7 0.09 145);
|
||||
--chart-3: oklch(0.82 0.12 90);
|
||||
--chart-4: oklch(0.7 0.15 28);
|
||||
--chart-5: oklch(0.72 0.08 250);
|
||||
--sidebar: oklch(0.235 0.018 55);
|
||||
--sidebar-foreground: oklch(0.95 0.018 80);
|
||||
--sidebar-primary: oklch(0.8 0.14 78);
|
||||
--sidebar-primary-foreground: oklch(0.19 0.03 55);
|
||||
--sidebar-accent: oklch(0.31 0.03 60);
|
||||
--sidebar-accent-foreground: oklch(0.95 0.018 80);
|
||||
--sidebar-border: oklch(0.34 0.02 60);
|
||||
--sidebar-ring: oklch(0.82 0.13 80);
|
||||
--shadow-elevated: 0 1px 2px -1px oklch(0 0 0 / 30%), 0 8px 24px -8px oklch(0 0 0 / 45%);
|
||||
--shadow-soft: 0 1px 2px -1px oklch(0 0 0 / 25%), 0 4px 12px -4px oklch(0 0 0 / 30%);
|
||||
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
|
||||
@@ -245,7 +239,7 @@
|
||||
--holo-fill: oklch(0.78 0.14 220 / 10%);
|
||||
--holo-label-bg: oklch(0.26 0.04 250);
|
||||
--holo-label-fg: oklch(0.95 0.02 230);
|
||||
/* T5 tokens, end values (spec 4.2); the old values above switch in task A4. */
|
||||
/* T5 tokens (spec 4.2). */
|
||||
--primary-ink: oklch(0.82 0.13 80);
|
||||
--warning-ink: oklch(0.82 0.13 82);
|
||||
--destructive-foreground: oklch(0.17 0.03 30);
|
||||
@@ -364,51 +358,51 @@
|
||||
.dark,
|
||||
:root[data-contrast="sun"],
|
||||
.dark[data-contrast="sun"] {
|
||||
--background: oklch(0.955 0.017 80);
|
||||
--foreground: oklch(0.22 0.028 55);
|
||||
--background: oklch(0.968 0.014 85);
|
||||
--foreground: oklch(0.21 0.03 55);
|
||||
--card: oklch(0.995 0.004 90);
|
||||
--card-foreground: oklch(0.22 0.028 55);
|
||||
--card-foreground: oklch(0.21 0.03 55);
|
||||
--popover: oklch(0.995 0.004 90);
|
||||
--popover-foreground: oklch(0.22 0.028 55);
|
||||
--primary: oklch(0.6 0.15 48);
|
||||
--primary-foreground: oklch(0.99 0.02 85);
|
||||
--popover-foreground: oklch(0.21 0.03 55);
|
||||
--primary: oklch(0.8 0.145 78);
|
||||
--primary-foreground: oklch(0.21 0.035 55);
|
||||
--secondary: oklch(0.44 0.07 145);
|
||||
--secondary-foreground: oklch(0.98 0.015 100);
|
||||
--muted: oklch(0.9 0.02 82);
|
||||
--muted-foreground: oklch(0.48 0.032 65);
|
||||
--accent: oklch(0.93 0.032 74);
|
||||
--accent-foreground: oklch(0.26 0.045 50);
|
||||
--destructive: oklch(0.5 0.16 30);
|
||||
--border: oklch(0.85 0.024 78);
|
||||
--input: oklch(0.85 0.024 78);
|
||||
--ring: oklch(0.6 0.15 48);
|
||||
--warning: oklch(0.68 0.14 75);
|
||||
--warning-foreground: oklch(0.24 0.04 60);
|
||||
--success: oklch(0.46 0.06 145);
|
||||
--muted: oklch(0.925 0.018 82);
|
||||
--muted-foreground: oklch(0.44 0.03 60);
|
||||
--accent: oklch(0.935 0.03 78);
|
||||
--accent-foreground: oklch(0.25 0.045 50);
|
||||
--destructive: oklch(0.5 0.17 28);
|
||||
--border: oklch(0.86 0.022 78);
|
||||
--input: oklch(0.6 0.035 65);
|
||||
--ring: oklch(0.47 0.115 55);
|
||||
--warning: oklch(0.79 0.15 80);
|
||||
--warning-foreground: oklch(0.23 0.04 60);
|
||||
--success: oklch(0.46 0.08 145);
|
||||
--success-foreground: oklch(0.98 0.015 100);
|
||||
--condition-excellent: oklch(0.58 0.13 145);
|
||||
--condition-excellent-foreground: oklch(0.16 0.04 145);
|
||||
--condition-good: oklch(0.64 0.13 118);
|
||||
--condition-good-foreground: oklch(0.26 0.05 118);
|
||||
--condition-average: oklch(0.7 0.14 90);
|
||||
--condition-average-foreground: oklch(0.28 0.05 90);
|
||||
--condition-weak: oklch(0.68 0.15 55);
|
||||
--condition-weak-foreground: oklch(0.26 0.05 55);
|
||||
--condition-critical: oklch(0.64 0.16 30);
|
||||
--condition-excellent: oklch(0.62 0.13 150);
|
||||
--condition-excellent-foreground: oklch(0.15 0.04 150);
|
||||
--condition-good: oklch(0.645 0.13 125);
|
||||
--condition-good-foreground: oklch(0.17 0.04 125);
|
||||
--condition-average: oklch(0.665 0.125 88);
|
||||
--condition-average-foreground: oklch(0.19 0.04 90);
|
||||
--condition-weak: oklch(0.665 0.14 58);
|
||||
--condition-weak-foreground: oklch(0.18 0.04 55);
|
||||
--condition-critical: oklch(0.62 0.17 30);
|
||||
--condition-critical-foreground: oklch(0.15 0.04 30);
|
||||
--chart-1: oklch(0.6 0.135 50);
|
||||
--chart-2: oklch(0.46 0.06 145);
|
||||
--chart-3: oklch(0.68 0.14 75);
|
||||
--chart-4: oklch(0.5 0.16 30);
|
||||
--chart-5: oklch(0.55 0.05 250);
|
||||
--chart-1: oklch(0.56 0.13 55);
|
||||
--chart-2: oklch(0.46 0.08 145);
|
||||
--chart-3: oklch(0.6 0.12 85);
|
||||
--chart-4: oklch(0.5 0.17 28);
|
||||
--chart-5: oklch(0.52 0.08 250);
|
||||
--sidebar: oklch(0.995 0.004 90);
|
||||
--sidebar-foreground: oklch(0.22 0.028 55);
|
||||
--sidebar-primary: oklch(0.6 0.15 48);
|
||||
--sidebar-primary-foreground: oklch(0.99 0.02 85);
|
||||
--sidebar-accent: oklch(0.93 0.032 74);
|
||||
--sidebar-accent-foreground: oklch(0.26 0.045 50);
|
||||
--sidebar-border: oklch(0.85 0.024 78);
|
||||
--sidebar-ring: oklch(0.6 0.15 48);
|
||||
--sidebar-foreground: oklch(0.21 0.03 55);
|
||||
--sidebar-primary: oklch(0.8 0.145 78);
|
||||
--sidebar-primary-foreground: oklch(0.21 0.035 55);
|
||||
--sidebar-accent: oklch(0.935 0.03 78);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.045 50);
|
||||
--sidebar-border: oklch(0.86 0.022 78);
|
||||
--sidebar-ring: oklch(0.47 0.115 55);
|
||||
--shadow-elevated: 0 1px 2px -1px oklch(0.3 0.05 50 / 8%), 0 8px 24px -8px oklch(0.3 0.05 50 / 16%);
|
||||
--shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%);
|
||||
--holo-bg: oklch(0.18 0.03 250);
|
||||
@@ -515,7 +509,7 @@
|
||||
}
|
||||
input,
|
||||
textarea {
|
||||
caret-color: var(--primary);
|
||||
caret-color: var(--primary-ink);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,7 +581,7 @@
|
||||
position: absolute;
|
||||
inset: 0.375rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 2px solid color-mix(in oklch, var(--primary) 65%, var(--border));
|
||||
border: 2px solid var(--input);
|
||||
background: var(--card);
|
||||
transition:
|
||||
border-color 150ms ease-out,
|
||||
|
||||
@@ -62,7 +62,7 @@ interface InfoRowProps {
|
||||
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Icon className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<Icon className="mt-0.5 size-4 shrink-0 text-primary-ink" />
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="font-medium">{value}</dd>
|
||||
@@ -80,7 +80,7 @@ interface StatProps {
|
||||
function Stat({ icon: Icon, label, value }: StatProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl bg-accent/40 px-3 py-3 text-center">
|
||||
<Icon className="size-4 text-primary" />
|
||||
<Icon className="size-4 text-primary-ink" />
|
||||
<span className="text-sm font-semibold">{value}</span>
|
||||
<span className="text-[0.7rem] text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@ export default function PublicBatchPage() {
|
||||
{batch.productName ?? "Honig"}
|
||||
</h1>
|
||||
{batch.honeyType ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary-ink">
|
||||
<Sparkles className="size-3" />
|
||||
{batch.honeyType}
|
||||
</span>
|
||||
@@ -197,7 +197,7 @@ export default function PublicBatchPage() {
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-sm font-medium text-primary transition-colors hover:bg-primary/20"
|
||||
className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-sm font-medium text-primary-ink transition-colors hover:bg-primary/20"
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{link.label}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hexOf, oklchOf, tokenSet } from "@/styles/css-tokens";
|
||||
import { config as proxyConfig } from "@/proxy";
|
||||
import manifest from "./manifest";
|
||||
|
||||
// Spec 12.2/12.3 and 13.1: the web manifest, its files and what serves them.
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const PUBLIC_DIR = path.join(ROOT, "public");
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
interface Icon {
|
||||
src: string;
|
||||
sizes?: string;
|
||||
type?: string;
|
||||
purpose?: string;
|
||||
}
|
||||
|
||||
/** Width and height from the IHDR chunk of a PNG. */
|
||||
function pngSize(png: Buffer): { width: number; height: number } {
|
||||
expect(png.subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true);
|
||||
expect(png.toString("latin1", 12, 16)).toBe("IHDR");
|
||||
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
||||
}
|
||||
|
||||
function publicFile(src: string): string {
|
||||
return path.join(PUBLIC_DIR, ...src.split("/").filter(Boolean));
|
||||
}
|
||||
|
||||
const data = manifest();
|
||||
const icons: Icon[] = data.icons ?? [];
|
||||
const shortcutIcons: Icon[] = (data.shortcuts ?? []).flatMap((shortcut) => shortcut.icons ?? []);
|
||||
|
||||
describe("web manifest (spec 12.3)", () => {
|
||||
it("every manifest icon exists under public with the declared size", () => {
|
||||
const all = [...icons, ...shortcutIcons];
|
||||
expect(all.length).toBeGreaterThan(0);
|
||||
for (const icon of all) {
|
||||
const file = publicFile(icon.src);
|
||||
expect(existsSync(file), icon.src).toBe(true);
|
||||
if (icon.sizes === "any") {
|
||||
expect(icon.type).toBe("image/svg+xml");
|
||||
expect(readFileSync(file, "utf8")).toContain("<svg");
|
||||
continue;
|
||||
}
|
||||
expect(icon.type ?? "image/png").toBe("image/png");
|
||||
const [width, height] = (icon.sizes ?? "").split("x").map(Number);
|
||||
expect(pngSize(readFileSync(file)), icon.src).toEqual({ width, height });
|
||||
}
|
||||
});
|
||||
|
||||
it("declares any and maskable icons at 192 and 512 and one monochrome icon", () => {
|
||||
const sizesOf = (purpose: string) =>
|
||||
icons.filter((icon) => (icon.purpose ?? "any") === purpose).map((icon) => icon.sizes);
|
||||
|
||||
expect(sizesOf("any")).toEqual(expect.arrayContaining(["192x192", "512x512", "any"]));
|
||||
expect(sizesOf("maskable")).toEqual(["192x192", "512x512"]);
|
||||
expect(sizesOf("monochrome")).toEqual(["512x512"]);
|
||||
});
|
||||
|
||||
it("background_color and theme_color equal the light --background", () => {
|
||||
const css = readFileSync(path.join(ROOT, "src/app/globals.css"), "utf8");
|
||||
const lightBackground = hexOf(oklchOf(tokenSet(css, "light").background));
|
||||
|
||||
expect(lightBackground).toBe("#f9f4ea");
|
||||
expect(data.background_color).toBe(lightBackground);
|
||||
expect(data.theme_color).toBe(lightBackground);
|
||||
});
|
||||
|
||||
it("is installable from the root with German metadata", () => {
|
||||
expect(data).toMatchObject({
|
||||
id: "/",
|
||||
name: "ApiCore – Imkereiverwaltung",
|
||||
short_name: "ApiCore",
|
||||
lang: "de",
|
||||
dir: "ltr",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
categories: ["productivity", "business"],
|
||||
});
|
||||
});
|
||||
|
||||
it("offers exactly the three spec shortcuts with their 96 px icons", () => {
|
||||
expect(
|
||||
(data.shortcuts ?? []).map((shortcut) => ({
|
||||
name: shortcut.name,
|
||||
url: shortcut.url,
|
||||
icons: shortcut.icons?.map((icon) => `${icon.src} ${icon.sizes}`),
|
||||
})),
|
||||
).toEqual([
|
||||
{ name: "Durchsicht erfassen", url: "/inspections/new", icons: ["/icons/shortcut-inspection-96.png 96x96"] },
|
||||
{ name: "Varroa messen", url: "/varroa/new", icons: ["/icons/shortcut-varroa-96.png 96x96"] },
|
||||
{ name: "Heute", url: "/dashboard", icons: ["/icons/shortcut-today-96.png 96x96"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("every shortcut url is in SHELL_ROUTES", () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const { SHELL_ROUTES } = require(path.join(PUBLIC_DIR, "sw-routes.js")) as {
|
||||
SHELL_ROUTES: readonly string[];
|
||||
};
|
||||
for (const shortcut of data.shortcuts ?? []) {
|
||||
expect(SHELL_ROUTES, shortcut.url).toContain(shortcut.url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("app icons (spec 12.2)", () => {
|
||||
it("favicon.ico holds 16, 32 and 48 px entries", () => {
|
||||
const ico = readFileSync(path.join(ROOT, "src/app/favicon.ico"));
|
||||
expect(ico.readUInt16LE(0)).toBe(0);
|
||||
expect(ico.readUInt16LE(2)).toBe(1);
|
||||
const count = ico.readUInt16LE(4);
|
||||
const entries = Array.from({ length: count }, (_, index) => {
|
||||
const at = 6 + index * 16;
|
||||
const width = ico.readUInt8(at);
|
||||
const length = ico.readUInt32LE(at + 8);
|
||||
const offset = ico.readUInt32LE(at + 12);
|
||||
const image = ico.subarray(offset, offset + length);
|
||||
expect(image.length).toBe(length);
|
||||
expect(pngSize(image)).toEqual({ width, height: ico.readUInt8(at + 1) });
|
||||
return width;
|
||||
});
|
||||
|
||||
expect(entries).toEqual([16, 32, 48]);
|
||||
});
|
||||
|
||||
it("apple-icon.png is a 180 px PNG and replaces the generated apple-icon route", () => {
|
||||
expect(pngSize(readFileSync(path.join(ROOT, "src/app/apple-icon.png")))).toEqual({ width: 180, height: 180 });
|
||||
expect(existsSync(path.join(ROOT, "src/app/apple-icon.tsx"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy matcher", () => {
|
||||
const [matcher] = proxyConfig.matcher;
|
||||
const runsProxy = (pathname: string) => new RegExp(`^${matcher}$`).test(pathname);
|
||||
|
||||
it("the proxy matcher excludes icons and apple-icon.png", () => {
|
||||
for (const pathname of [
|
||||
"/icons/icon-192.png",
|
||||
"/icons/maskable-512.png",
|
||||
"/icons/shortcut-today-96.png",
|
||||
"/apple-icon.png",
|
||||
"/icon.svg",
|
||||
"/favicon.ico",
|
||||
"/sw.js",
|
||||
"/manifest.webmanifest",
|
||||
]) {
|
||||
expect(runsProxy(pathname), pathname).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the security headers on pages that only resemble an excluded file", () => {
|
||||
for (const pathname of [
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/inspections/new",
|
||||
"/icons",
|
||||
"/icons/x/y",
|
||||
"/icons/missing",
|
||||
"/iconsx/page",
|
||||
"/apple-iconxpng",
|
||||
"/iconxsvg",
|
||||
"/faviconxico",
|
||||
"/swxjs",
|
||||
]) {
|
||||
expect(runsProxy(pathname), pathname).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,49 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { THEME_COLORS } from "@/lib/appearance/appearance-core";
|
||||
|
||||
// Spec 12.3. The icon files come from `npm run icons` (scripts/generate-icons.mjs);
|
||||
// the shortcut URLs are shell routes of the service worker, so they open offline too.
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
id: "/",
|
||||
name: "ApiCore – Imkereiverwaltung",
|
||||
short_name: "ApiCore",
|
||||
description: "Digitale Imkereiverwaltung für Wabenhain",
|
||||
lang: "de",
|
||||
dir: "ltr",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
theme_color: "#0a0a0a",
|
||||
// = light --background (manifest.spec checks it against globals.css); dark
|
||||
// comes from viewport.themeColor in the root layout.
|
||||
background_color: THEME_COLORS.light,
|
||||
theme_color: THEME_COLORS.light,
|
||||
categories: ["productivity", "business"],
|
||||
icons: [
|
||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/maskable-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
{ src: "/icons/monochrome-512.png", sizes: "512x512", type: "image/png", purpose: "monochrome" },
|
||||
{ src: "/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
|
||||
],
|
||||
shortcuts: [
|
||||
{
|
||||
src: "/icon.svg",
|
||||
sizes: "any",
|
||||
type: "image/svg+xml",
|
||||
name: "Durchsicht erfassen",
|
||||
short_name: "Durchsicht",
|
||||
url: "/inspections/new",
|
||||
icons: [{ src: "/icons/shortcut-inspection-96.png", sizes: "96x96", type: "image/png" }],
|
||||
},
|
||||
{
|
||||
name: "Varroa messen",
|
||||
short_name: "Varroa",
|
||||
url: "/varroa/new",
|
||||
icons: [{ src: "/icons/shortcut-varroa-96.png", sizes: "96x96", type: "image/png" }],
|
||||
},
|
||||
{
|
||||
name: "Heute",
|
||||
url: "/dashboard",
|
||||
icons: [{ src: "/icons/shortcut-today-96.png", sizes: "96x96", type: "image/png" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ApiaryMap({ apiaries }: ApiaryMapProps) {
|
||||
{apiaries.map((apiary) => (
|
||||
<Marker key={apiary.id} position={[apiary.latitude, apiary.longitude]} icon={markerIcon}>
|
||||
<Popup>
|
||||
<Link href={`/apiaries/${apiary.id}`} className="font-medium text-primary underline">
|
||||
<Link href={`/apiaries/${apiary.id}`} className="font-medium text-primary-ink underline">
|
||||
{apiary.name}
|
||||
</Link>
|
||||
{apiary.location ? <p className="text-sm text-muted-foreground">{apiary.location}</p> : null}
|
||||
|
||||
@@ -23,7 +23,7 @@ afterEach(() => {
|
||||
|
||||
describe("appearanceScript", () => {
|
||||
it("uses the same storage keys as the hook", () => {
|
||||
const source = appearanceScript(true);
|
||||
const source = appearanceScript();
|
||||
for (const key of Object.values(APPEARANCE_KEYS)) expect(source).toContain(key);
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("appearanceScript", () => {
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.contrast, "sun");
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.motion, "subtle");
|
||||
|
||||
run(appearanceScript(true));
|
||||
run(appearanceScript());
|
||||
|
||||
const root = document.documentElement;
|
||||
expect(root).toHaveClass("dark");
|
||||
@@ -44,29 +44,25 @@ describe("appearanceScript", () => {
|
||||
const themes = [null, "light", "dark", "system", "bogus"];
|
||||
const contrasts = [null, "normal", "sun", "bogus"];
|
||||
const motions = [null, "full", "subtle", "off", "bogus"];
|
||||
for (const darkEnabled of [true, false])
|
||||
for (const prefersDark of [true, false])
|
||||
for (const prefersMore of [true, false])
|
||||
for (const theme of themes)
|
||||
for (const contrast of contrasts)
|
||||
for (const motion of motions) {
|
||||
resetDocument();
|
||||
stubMedia(prefersDark, prefersMore);
|
||||
if (theme) window.localStorage.setItem(APPEARANCE_KEYS.theme, theme);
|
||||
if (contrast) window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast);
|
||||
if (motion) window.localStorage.setItem(APPEARANCE_KEYS.motion, motion);
|
||||
for (const prefersDark of [true, false])
|
||||
for (const prefersMore of [true, false])
|
||||
for (const theme of themes)
|
||||
for (const contrast of contrasts)
|
||||
for (const motion of motions) {
|
||||
resetDocument();
|
||||
stubMedia(prefersDark, prefersMore);
|
||||
if (theme) window.localStorage.setItem(APPEARANCE_KEYS.theme, theme);
|
||||
if (contrast) window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast);
|
||||
if (motion) window.localStorage.setItem(APPEARANCE_KEYS.motion, motion);
|
||||
|
||||
run(appearanceScript(darkEnabled));
|
||||
run(appearanceScript());
|
||||
|
||||
const expected = resolveAppearance(
|
||||
{ theme, contrast, motion },
|
||||
{ prefersDark, prefersMore, darkEnabled },
|
||||
);
|
||||
const root = document.documentElement;
|
||||
expect(root.classList.contains("dark")).toBe(expected.dark);
|
||||
expect(root.dataset.contrast).toBe(expected.sun ? "sun" : "normal");
|
||||
expect(root.dataset.motion).toBe(expected.motion);
|
||||
}
|
||||
const expected = resolveAppearance({ theme, contrast, motion }, { prefersDark, prefersMore });
|
||||
const root = document.documentElement;
|
||||
expect(root.classList.contains("dark")).toBe(expected.dark);
|
||||
expect(root.dataset.contrast).toBe(expected.sun ? "sun" : "normal");
|
||||
expect(root.dataset.motion).toBe(expected.motion);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocked storage falls back to the default", () => {
|
||||
@@ -78,7 +74,7 @@ describe("appearanceScript", () => {
|
||||
},
|
||||
});
|
||||
try {
|
||||
run(appearanceScript(true));
|
||||
run(appearanceScript());
|
||||
} finally {
|
||||
Object.defineProperty(window, "localStorage", real);
|
||||
}
|
||||
@@ -102,14 +98,18 @@ describe("appearanceScript", () => {
|
||||
for (const [theme, contrast, color] of cases) {
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.theme, theme);
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast);
|
||||
run(appearanceScript(true));
|
||||
run(appearanceScript());
|
||||
expect(meta.content).toBe(color);
|
||||
}
|
||||
});
|
||||
|
||||
it("has no build flag for dark any more (task A4)", () => {
|
||||
expect(appearanceScript()).not.toMatch(/DARK=/);
|
||||
});
|
||||
|
||||
it("contains no stored or user data, only constants", () => {
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.theme, "secret-value");
|
||||
expect(appearanceScript(true)).not.toContain("secret-value");
|
||||
expect(appearanceScript()).not.toContain("secret-value");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export function AppearanceScript() {
|
||||
return (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: appearanceScript(process.env.NEXT_PUBLIC_APPEARANCE_DARK === "1"),
|
||||
__html: appearanceScript(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -27,10 +27,8 @@ describe("area migration", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("no source other than globals.css uses the --gradient tokens", () => {
|
||||
const offenders = sourceFiles(SRC)
|
||||
.filter((file) => !file.endsWith("globals.css"))
|
||||
.filter((file) => /--gradient-(honey|sage|sky)/.test(readFileSync(file, "utf8")));
|
||||
it("no source uses or defines the --gradient tokens any more (removed in task A4)", () => {
|
||||
const offenders = sourceFiles(SRC).filter((file) => /--gradient-/.test(readFileSync(file, "utf8")));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ export function NextStepsView({
|
||||
className="flex flex-col gap-3 rounded-xl border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary-ink">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
|
||||
@@ -13,6 +13,13 @@ describe("BrandMark", () => {
|
||||
expect(screen.getByText("ApiCore")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fills the cell with --primary (= spec honey #f0b13f, see contrast.spec) and the lid with wax", () => {
|
||||
const { container } = render(<BrandMark />);
|
||||
const [cell, lid] = container.querySelectorAll("svg path");
|
||||
expect(cell).toHaveAttribute("fill", "var(--primary)");
|
||||
expect(lid).toHaveAttribute("fill", "oklch(0.88 0.11 85)");
|
||||
});
|
||||
|
||||
it("is decorative without a wordmark and sized 24 or 32 px", () => {
|
||||
const { container, rerender } = render(<BrandMark />);
|
||||
const svg = container.querySelector("svg") as SVGElement;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
|
||||
*/
|
||||
export const BRAND_CELL_PATHS = {
|
||||
cell: "M32 4 L56 18 V46 L32 60 L8 46 V18 Z",
|
||||
lid: "M32 13.6 L45.2 21.3 V36.7 L32 44.4 L19.8 36.7 V21.3 Z",
|
||||
lid: "M32 13.6 L45.2 21.3 V36.7 L32 44.4 L18.8 36.7 V21.3 Z",
|
||||
} as const;
|
||||
|
||||
interface BrandMarkProps {
|
||||
@@ -15,7 +15,11 @@ interface BrandMarkProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The ApiCore mark: a capped cell, optionally followed by the wordmark. */
|
||||
/**
|
||||
* The ApiCore mark: a capped cell, optionally followed by the wordmark. The cell is --primary:
|
||||
* its light value is the spec honey #f0b13f (pinned in contrast.spec), dark differs by one step
|
||||
* (#efb146), so no separate brand token is needed. Static files (task A9) use the hex directly.
|
||||
*/
|
||||
export function BrandMark({ size = 24, withWordmark = false, className }: BrandMarkProps) {
|
||||
const mark = (
|
||||
<svg
|
||||
|
||||
@@ -118,7 +118,7 @@ export function HarvestRestrictionWarning({ apiaryId }: { apiaryId: string }) {
|
||||
<div aria-live="polite">
|
||||
{restricted ? (
|
||||
<p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||
<span>Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -142,7 +142,7 @@ export function ChoiceGroup<T extends ChoiceValue>({
|
||||
selected && isUnanswered && "border-foreground/30 bg-muted font-medium text-foreground",
|
||||
selected &&
|
||||
!isUnanswered &&
|
||||
"border-primary bg-primary font-medium text-primary-foreground",
|
||||
"border-foreground bg-primary font-medium text-primary-foreground",
|
||||
selected && option.selectedClassName,
|
||||
optionClassName,
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ToggleChip({ label, checked, onChange, describedBy, disabled }:
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-2 rounded-lg border px-3 text-left text-sm motion-safe:transition-colors disabled:pointer-events-none disabled:opacity-50",
|
||||
checked
|
||||
? "border-primary bg-primary/10 font-medium"
|
||||
? "border-foreground bg-primary/10 font-medium"
|
||||
: "border-border bg-background hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
@@ -34,7 +34,7 @@ export function ToggleChip({ label, checked, onChange, describedBy, disabled }:
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded border",
|
||||
checked
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
? "border-foreground bg-primary text-primary-foreground"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function LabelCompliancePanel({
|
||||
<ul aria-label="Hinweise" className="flex flex-col gap-2">
|
||||
{result.warnings.map((warning, index) => (
|
||||
<li key={`${warning.code}-${index}`} className="flex items-start gap-2 text-sm">
|
||||
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning-ink" />
|
||||
<span className="flex flex-col">
|
||||
<span className="font-medium">Hinweis: {warning.title}</span>
|
||||
{warning.detail ? (
|
||||
|
||||
@@ -37,7 +37,7 @@ export function PackAgeNote({ stored, now }: PackAgeNoteProps) {
|
||||
<p className="text-muted-foreground">Geladen {packAgeText(stored.savedAt, now)}</p>
|
||||
{isPackStale(stored, now) ? (
|
||||
<p className="flex items-center gap-1.5 font-medium">
|
||||
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden />
|
||||
<AlertTriangle className="size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||
{`Stand-Daten von ${packDayText(stored.savedAt)} – bei Netz neu laden`}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function ApiarySeason({ apiary }: ApiarySeasonProps) {
|
||||
</>
|
||||
) : null}
|
||||
{state.note ? (
|
||||
<span className="text-warning-foreground">{SEASON_NOTE_LABELS[state.note]}</span>
|
||||
<span className="text-warning-ink">{SEASON_NOTE_LABELS[state.note]}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function RoundNextPanel({ next, doneCount, savedOffline, onNext, onEnd }:
|
||||
<Card className="max-w-lg">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<p role="status" className="flex items-center gap-2 text-lg font-medium">
|
||||
<CheckCircle2 className="size-5 text-condition-good-foreground" aria-hidden />
|
||||
<CheckCircle2 className="size-5 text-condition-good-ink" aria-hidden />
|
||||
Gespeichert
|
||||
</p>
|
||||
{savedOffline ? (
|
||||
|
||||
@@ -190,7 +190,7 @@ function MovementList({ movements }: { movements: StockMovement[] }) {
|
||||
className={
|
||||
movement.quantity < 0
|
||||
? "font-semibold tabular-nums text-destructive"
|
||||
: "font-semibold tabular-nums text-primary"
|
||||
: "font-semibold tabular-nums text-primary-ink"
|
||||
}
|
||||
>
|
||||
{movement.quantity > 0 ? "+" : ""}
|
||||
|
||||
@@ -109,7 +109,7 @@ export function SuggestionCard(props: SuggestionCardProps) {
|
||||
return (
|
||||
<article className="flex min-w-0 flex-col gap-3 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary-ink">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
|
||||
@@ -62,7 +62,7 @@ function CombinedRow({
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary-ink">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h3 className="min-w-0 flex-1 font-medium">
|
||||
|
||||
@@ -56,7 +56,7 @@ export function TaskSourceBadge({
|
||||
title={source.title}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-dashed border-primary/45 px-2 py-0.5 text-xs text-foreground/80"
|
||||
>
|
||||
<Icon className="size-3.5 text-primary" aria-hidden />
|
||||
<Icon className="size-3.5 text-primary-ink" aria-hidden />
|
||||
{source.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ export function InspectionDueCard({ entry, primary = true }: InspectionDueCardPr
|
||||
className="flex flex-col gap-3 rounded-2xl border border-border/60 bg-card p-4 shadow-[var(--shadow-soft)] sm:p-5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary-ink">
|
||||
<ClipboardCheck className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h3 id={headingId} className="min-w-0 flex-1 font-medium">
|
||||
|
||||
@@ -157,7 +157,7 @@ export function TodayPanel({
|
||||
</h2>
|
||||
<Link
|
||||
href="/tasks"
|
||||
className="inline-flex min-h-11 items-center text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
className="inline-flex min-h-11 items-center text-sm font-medium text-primary-ink underline-offset-4 hover:underline"
|
||||
>
|
||||
Alle Aufgaben
|
||||
</Link>
|
||||
@@ -307,7 +307,7 @@ function AutopilotStepCard({ step, busy, ...handlers }: AutopilotStepCardProps)
|
||||
<div key={step.key} className="next-step-enter mt-2 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="flex items-start gap-3 font-heading text-2xl font-semibold leading-tight tracking-tight text-balance sm:text-3xl">
|
||||
<Icon className="mt-1 size-6 shrink-0 text-primary" aria-hidden />
|
||||
<Icon className="mt-1 size-6 shrink-0 text-primary-ink" aria-hidden />
|
||||
{step.title}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -445,7 +445,7 @@ function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps)
|
||||
function NothingDue() {
|
||||
return (
|
||||
<section className="flex flex-col items-start gap-4 rounded-2xl border border-border/60 bg-card p-5 shadow-[var(--shadow-soft)] sm:p-6">
|
||||
<span className="grid size-11 place-items-center rounded-full bg-primary/12 text-primary">
|
||||
<span className="grid size-11 place-items-center rounded-full bg-primary/12 text-primary-ink">
|
||||
<Sun className="size-6" aria-hidden />
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -321,7 +321,7 @@ export function BookingNotes({
|
||||
) : null}
|
||||
{warnings.map((warning) => (
|
||||
<p key={warning} className="flex items-start gap-2 text-sm font-medium">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||
<span>{warning}</span>
|
||||
</p>
|
||||
))}
|
||||
@@ -366,7 +366,7 @@ function TemperatureNote({
|
||||
const Icon = hint.warn ? AlertTriangle : Thermometer;
|
||||
return (
|
||||
<p data-warn={hint.warn} className="flex flex-wrap items-start gap-x-2 text-sm">
|
||||
<Icon className={`mt-0.5 size-4 shrink-0 ${hint.warn ? "text-warning" : ""}`} aria-hidden />
|
||||
<Icon className={`mt-0.5 size-4 shrink-0 ${hint.warn ? "text-warning-ink" : ""}`} aria-hidden />
|
||||
<span>{hint.text}</span>
|
||||
{hint.warn ? <span className="font-medium">Außerhalb des Katalog-Bereichs</span> : null}
|
||||
</p>
|
||||
|
||||
@@ -17,7 +17,7 @@ function Avatar({
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
"group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -13,14 +13,14 @@ const badgeVariants = cva(
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 [a]:hover:bg-destructive/20",
|
||||
warning:
|
||||
"bg-warning/15 text-warning-foreground focus-visible:ring-warning/30 [a]:hover:bg-warning/25",
|
||||
"bg-warning/15 text-warning-ink focus-visible:ring-warning/30 [a]:hover:bg-warning/25",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"hover:bg-muted hover:text-muted-foreground",
|
||||
link: "text-primary-ink underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -4,21 +4,21 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-[var(--shadow-soft)] hover:shadow-[var(--shadow-elevated)] hover:brightness-105 active:brightness-95",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20",
|
||||
link: "text-primary-ink underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
// Phones get at least 40 px: tapped with a thumb, often with gloves.
|
||||
|
||||
@@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer flex size-4 shrink-0 items-center justify-center rounded-[6px] border border-input bg-background shadow-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground",
|
||||
"peer flex size-4 shrink-0 items-center justify-center rounded-[6px] border border-input bg-background shadow-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-checked:border-foreground data-checked:bg-primary data-checked:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -88,7 +88,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-11 w-full min-w-0 sm:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
"h-11 w-full min-w-0 sm:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||