diff --git a/src/tools/benchmark-builder/benchmark-builder.models.ts b/src/tools/benchmark-builder/benchmark-builder.models.ts
index 10ae8a78..0bf3456b 100644
--- a/src/tools/benchmark-builder/benchmark-builder.models.ts
+++ b/src/tools/benchmark-builder/benchmark-builder.models.ts
@@ -18,7 +18,7 @@ function computeVariance({ data }: { data: number[] }) {
   return computeAverage({ data: squaredDiffs });
 }
 
-function arrayToMarkdownTable({ data, headerMap = {} }: { data: unknown[]; headerMap?: Record
 }) {
+function arrayToMarkdownTable({ data, headerMap = {} }: { data: Record[]; headerMap?: Record }) {
   if (!Array.isArray(data) || data.length === 0) {
     return '';
   }
diff --git a/src/tools/benchmark-builder/benchmark-builder.vue b/src/tools/benchmark-builder/benchmark-builder.vue
index 91f3155d..7922791c 100644
--- a/src/tools/benchmark-builder/benchmark-builder.vue
+++ b/src/tools/benchmark-builder/benchmark-builder.vue
@@ -1,10 +1,11 @@
 
 
 
    
diff --git a/src/tools/iban-validator-and-parser/iban-validator-and-parser.e2e.spec.ts b/src/tools/iban-validator-and-parser/iban-validator-and-parser.e2e.spec.ts
new file mode 100644
index 00000000..c4a99860
--- /dev/null
+++ b/src/tools/iban-validator-and-parser/iban-validator-and-parser.e2e.spec.ts
@@ -0,0 +1,52 @@
+import { type Page, expect, test } from '@playwright/test';
+
+async function extractIbanInfo({ page }: { page: Page }) {
+  const itemsLines = await page
+    .locator('.c-key-value-list__item').all();
+
+  return await Promise.all(
+    itemsLines.map(async item => [
+      (await item.locator('.c-key-value-list__key').textContent() ?? '').trim(),
+      (await item.locator('.c-key-value-list__value').textContent() ?? '').trim(),
+    ]),
+  );
+}
+
+test.describe('Tool - Iban validator and parser', () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto('/iban-validator-and-parser');
+  });
+
+  test('Has correct title', async ({ page }) => {
+    await expect(page).toHaveTitle('IBAN validator and parser - IT Tools');
+  });
+
+  test('iban info are extracted from a valid iban', async ({ page }) => {
+    await page.getByTestId('iban-input').fill('DE89370400440532013000');
+
+    const ibanInfo = await extractIbanInfo({ page });
+
+    expect(ibanInfo).toEqual([
+      ['Is IBAN valid ?', 'Yes'],
+      ['Is IBAN a QR-IBAN ?', 'No'],
+      ['Country code', 'DE'],
+      ['BBAN', '370400440532013000'],
+      ['IBAN friendly format', 'DE89 3704 0044 0532 0130 00'],
+    ]);
+  });
+
+  test('invalid iban errors are displayed', async ({ page }) => {
+    await page.getByTestId('iban-input').fill('FR7630006060011234567890189');
+
+    const ibanInfo = await extractIbanInfo({ page });
+
+    expect(ibanInfo).toEqual([
+      ['Is IBAN valid ?', 'No'],
+      ['IBAN errors', 'Wrong account bank branch checksum Wrong IBAN checksum'],
+      ['Is IBAN a QR-IBAN ?', 'No'],
+      ['Country code', 'N/A'],
+      ['BBAN', 'N/A'],
+      ['IBAN friendly format', 'FR76 3000 6060 0112 3456 7890 189'],
+    ]);
+  });
+});
diff --git a/src/tools/iban-validator-and-parser/iban-validator-and-parser.service.ts b/src/tools/iban-validator-and-parser/iban-validator-and-parser.service.ts
new file mode 100644
index 00000000..bde71dba
--- /dev/null
+++ b/src/tools/iban-validator-and-parser/iban-validator-and-parser.service.ts
@@ -0,0 +1,18 @@
+import { ValidationErrorsIBAN } from 'ibantools';
+
+export { getFriendlyErrors };
+
+const ibanErrorToMessage = {
+  [ValidationErrorsIBAN.NoIBANProvided]: 'No IBAN provided',
+  [ValidationErrorsIBAN.NoIBANCountry]: 'No IBAN country',
+  [ValidationErrorsIBAN.WrongBBANLength]: 'Wrong BBAN length',
+  [ValidationErrorsIBAN.WrongBBANFormat]: 'Wrong BBAN format',
+  [ValidationErrorsIBAN.ChecksumNotNumber]: 'Checksum is not a number',
+  [ValidationErrorsIBAN.WrongIBANChecksum]: 'Wrong IBAN checksum',
+  [ValidationErrorsIBAN.WrongAccountBankBranchChecksum]: 'Wrong account bank branch checksum',
+  [ValidationErrorsIBAN.QRIBANNotAllowed]: 'QR-IBAN not allowed',
+};
+
+function getFriendlyErrors(errorCodes: ValidationErrorsIBAN[]) {
+  return errorCodes.map(errorCode => ibanErrorToMessage[errorCode]).filter(Boolean);
+}
diff --git a/src/tools/iban-validator-and-parser/iban-validator-and-parser.vue b/src/tools/iban-validator-and-parser/iban-validator-and-parser.vue
new file mode 100644
index 00000000..647be983
--- /dev/null
+++ b/src/tools/iban-validator-and-parser/iban-validator-and-parser.vue
@@ -0,0 +1,71 @@
+
+
+
+  
+ 
diff --git a/src/tools/iban-validator-and-parser/index.ts b/src/tools/iban-validator-and-parser/index.ts
new file mode 100644
index 00000000..b0cae50d
--- /dev/null
+++ b/src/tools/iban-validator-and-parser/index.ts
@@ -0,0 +1,12 @@
+import { defineTool } from '../tool';
+import Bank from '~icons/mdi/bank';
+
+export const tool = defineTool({
+  name: 'IBAN validator and parser',
+  path: '/iban-validator-and-parser',
+  description: 'Validate and parse IBAN numbers. Check if IBAN is valid and get the country, BBAN, if it is a QR-IBAN and the IBAN friendly format.',
+  keywords: ['iban', 'validator', 'and', 'parser', 'bic', 'bank'],
+  component: () => import('./iban-validator-and-parser.vue'),
+  icon: Bank,
+  createdAt: new Date('2023-08-26'),
+});
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 0cfc1739..cc5f42ee 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -1,6 +1,10 @@
 import { tool as base64FileConverter } from './base64-file-converter';
 import { tool as base64StringConverter } from './base64-string-converter';
 import { tool as basicAuthGenerator } from './basic-auth-generator';
+import { tool as ulidGenerator } from './ulid-generator';
+import { tool as ibanValidatorAndParser } from './iban-validator-and-parser';
+import { tool as stringObfuscator } from './string-obfuscator';
+import { tool as textDiff } from './text-diff';
 import { tool as emojiPicker } from './emoji-picker';
 import { tool as passwordStrengthAnalyser } from './password-strength-analyser';
 import { tool as yamlToToml } from './yaml-to-toml';
@@ -53,6 +57,7 @@ import { tool as metaTagGenerator } from './meta-tag-generator';
 import { tool as mimeTypes } from './mime-types';
 import { tool as otpCodeGeneratorAndValidator } from './otp-code-generator-and-validator';
 import { tool as qrCodeGenerator } from './qr-code-generator';
+import { tool as wifiQrCodeGenerator } from './wifi-qr-code-generator';
 import { tool as randomPortGenerator } from './random-port-generator';
 import { tool as romanNumeralConverter } from './roman-numeral-converter';
 import { tool as sqlPrettify } from './sql-prettify';
@@ -70,7 +75,7 @@ import { tool as xmlFormatter } from './xml-formatter';
 export const toolsByCategory: ToolCategory[] = [
   {
     name: 'Crypto',
-    components: [tokenGenerator, hashText, bcrypt, uuidGenerator, cypher, bip39, hmacGenerator, rsaKeyPairGenerator, passwordStrengthAnalyser],
+    components: [tokenGenerator, hashText, bcrypt, uuidGenerator, ulidGenerator, cypher, bip39, hmacGenerator, rsaKeyPairGenerator, passwordStrengthAnalyser],
   },
   {
     name: 'Converter',
@@ -114,7 +119,7 @@ export const toolsByCategory: ToolCategory[] = [
   },
   {
     name: 'Images and videos',
-    components: [qrCodeGenerator, svgPlaceholderGenerator, cameraRecorder],
+    components: [qrCodeGenerator, wifiQrCodeGenerator, svgPlaceholderGenerator, cameraRecorder],
   },
   {
     name: 'Development',
@@ -145,11 +150,11 @@ export const toolsByCategory: ToolCategory[] = [
   },
   {
     name: 'Text',
-    components: [loremIpsumGenerator, textStatistics, emojiPicker],
+    components: [loremIpsumGenerator, textStatistics, emojiPicker, stringObfuscator, textDiff],
   },
   {
     name: 'Data',
-    components: [phoneParserAndFormatter],
+    components: [phoneParserAndFormatter, ibanValidatorAndParser],
   },
 ];
 
diff --git a/src/tools/ipv4-address-converter/ipv4-address-converter.service.ts b/src/tools/ipv4-address-converter/ipv4-address-converter.service.ts
index 1ef487eb..903ff5bb 100644
--- a/src/tools/ipv4-address-converter/ipv4-address-converter.service.ts
+++ b/src/tools/ipv4-address-converter/ipv4-address-converter.service.ts
@@ -23,7 +23,7 @@ function ipv4ToIpv6({ ip, prefix = '0000:0000:0000:0000:0000:ffff:' }: { ip: str
     + _.chain(ip)
       .trim()
       .split('.')
-      .map(part => parseInt(part).toString(16).padStart(2, '0'))
+      .map(part => Number.parseInt(part).toString(16).padStart(2, '0'))
       .chunk(2)
       .map(blocks => blocks.join(''))
       .join(':')
diff --git a/src/tools/ipv4-range-expander/ipv4-range-expander.service.ts b/src/tools/ipv4-range-expander/ipv4-range-expander.service.ts
index 78fbde5b..14761f59 100644
--- a/src/tools/ipv4-range-expander/ipv4-range-expander.service.ts
+++ b/src/tools/ipv4-range-expander/ipv4-range-expander.service.ts
@@ -13,7 +13,7 @@ function getRangesize(start: string, end: string) {
     return -1;
   }
 
-  return 1 + parseInt(end, 2) - parseInt(start, 2);
+  return 1 + Number.parseInt(end, 2) - Number.parseInt(start, 2);
 }
 
 function getCidr(start: string, end: string) {
@@ -55,8 +55,8 @@ function calculateCidr({ startIp, endIp }: { startIp: string; endIp: string }) {
   const cidr = getCidr(start, end);
   if (cidr != null) {
     const result: Ipv4RangeExpanderResult = {};
-    result.newEnd = bits2ip(parseInt(cidr.end, 2));
-    result.newStart = bits2ip(parseInt(cidr.start, 2));
+    result.newEnd = bits2ip(Number.parseInt(cidr.end, 2));
+    result.newStart = bits2ip(Number.parseInt(cidr.start, 2));
     result.newCidr = `${result.newStart}/${cidr.mask}`;
     result.newSize = getRangesize(cidr.start, cidr.end);
 
diff --git a/src/tools/jwt-parser/jwt-parser.service.ts b/src/tools/jwt-parser/jwt-parser.service.ts
index 19edc5f2..cc39145a 100644
--- a/src/tools/jwt-parser/jwt-parser.service.ts
+++ b/src/tools/jwt-parser/jwt-parser.service.ts
@@ -1,6 +1,5 @@
 import jwtDecode, { type JwtHeader, type JwtPayload } from 'jwt-decode';
 import _ from 'lodash';
-import { match } from 'ts-pattern';
 import { ALGORITHM_DESCRIPTIONS, CLAIM_DESCRIPTIONS } from './jwt-parser.constants';
 
 export { decodeJwt };
@@ -32,10 +31,15 @@ function parseClaims({ claim, value }: { claim: string; value: unknown }) {
 }
 
 function getFriendlyValue({ claim, value }: { claim: string; value: unknown }) {
-  return match(claim)
-    .with('exp', 'nbf', 'iat', () => dateFormatter(value))
-    .with('alg', () => (_.isString(value) ? ALGORITHM_DESCRIPTIONS[value] : undefined))
-    .otherwise(() => undefined);
+  if (['exp', 'nbf', 'iat'].includes(claim)) {
+    return dateFormatter(value);
+  }
+
+  if (claim === 'alg' && _.isString(value)) {
+    return ALGORITHM_DESCRIPTIONS[value];
+  }
+
+  return undefined;
 }
 
 function dateFormatter(value: unknown) {
diff --git a/src/tools/mac-address-lookup/mac-address-lookup.vue b/src/tools/mac-address-lookup/mac-address-lookup.vue
index ef0927d5..82628805 100644
--- a/src/tools/mac-address-lookup/mac-address-lookup.vue
+++ b/src/tools/mac-address-lookup/mac-address-lookup.vue
@@ -8,7 +8,7 @@ const getVendorValue = (address: string) => address.trim().replace(/[.:-]/g, '')
 const macAddress = ref('20:37:06:12:34:56');
 const details = computed(() => db[getVendorValue(macAddress.value)]);
 
-const { copy } = useCopy({ source: details, text: 'Vendor info copied to the clipboard' });
+const { copy } = useCopy({ source: () => details.value ?? '', text: 'Vendor info copied to the clipboard' });
 
 
 
diff --git a/src/tools/otp-code-generator-and-validator/otp.service.ts b/src/tools/otp-code-generator-and-validator/otp.service.ts
index fae3d37c..fb4c33c9 100644
--- a/src/tools/otp-code-generator-and-validator/otp.service.ts
+++ b/src/tools/otp-code-generator-and-validator/otp.service.ts
@@ -15,7 +15,7 @@ export {
 };
 
 function hexToBytes(hex: string) {
-  return (hex.match(/.{1,2}/g) ?? []).map(char => parseInt(char, 16));
+  return (hex.match(/.{1,2}/g) ?? []).map(char => Number.parseInt(char, 16));
 }
 
 function computeHMACSha1(message: string, key: string) {
@@ -32,7 +32,7 @@ function base32toHex(base32: string) {
     .map(value => base32Chars.indexOf(value).toString(2).padStart(5, '0'))
     .join('');
 
-  const hex = (bits.match(/.{1,8}/g) ?? []).map(chunk => parseInt(chunk, 2).toString(16).padStart(2, '0')).join('');
+  const hex = (bits.match(/.{1,8}/g) ?? []).map(chunk => Number.parseInt(chunk, 2).toString(16).padStart(2, '0')).join('');
 
   return hex;
 }
diff --git a/src/tools/otp-code-generator-and-validator/token-display.vue b/src/tools/otp-code-generator-and-validator/token-display.vue
index a40c9af9..5313b0be 100644
--- a/src/tools/otp-code-generator-and-validator/token-display.vue
+++ b/src/tools/otp-code-generator-and-validator/token-display.vue
@@ -1,10 +1,10 @@
 
diff --git a/src/tools/password-strength-analyser/password-strength-analyser.e2e.spec.ts b/src/tools/password-strength-analyser/password-strength-analyser.e2e.spec.ts
index a694c547..9a3c9319 100644
--- a/src/tools/password-strength-analyser/password-strength-analyser.e2e.spec.ts
+++ b/src/tools/password-strength-analyser/password-strength-analyser.e2e.spec.ts
@@ -14,6 +14,6 @@ test.describe('Tool - Password strength analyser', () => {
 
     const crackDuration = await page.getByTestId('crack-duration').textContent();
 
-    expect(crackDuration).toEqual('15,091 milleniums, 3 centurys');
+    expect(crackDuration).toEqual('15,091 millennia, 3 centuries');
   });
 });
diff --git a/src/tools/password-strength-analyser/password-strength-analyser.service.ts b/src/tools/password-strength-analyser/password-strength-analyser.service.ts
index 7197b001..aa281848 100644
--- a/src/tools/password-strength-analyser/password-strength-analyser.service.ts
+++ b/src/tools/password-strength-analyser/password-strength-analyser.service.ts
@@ -4,7 +4,7 @@ export { getPasswordCrackTimeEstimation, getCharsetLength };
 
 function prettifyExponentialNotation(exponentialNotation: number) {
   const [base, exponent] = exponentialNotation.toString().split('e');
-  const baseAsNumber = parseFloat(base);
+  const baseAsNumber = Number.parseFloat(base);
   const prettyBase = baseAsNumber % 1 === 0 ? baseAsNumber.toLocaleString() : baseAsNumber.toFixed(2);
   return exponent ? `${prettyBase}e${exponent}` : prettyBase;
 }
@@ -19,20 +19,20 @@ function getHumanFriendlyDuration({ seconds }: { seconds: number }) {
   }
 
   const timeUnits = [
-    { unit: 'millenium', secondsInUnit: 31536000000, format: prettifyExponentialNotation },
-    { unit: 'century', secondsInUnit: 3153600000 },
-    { unit: 'decade', secondsInUnit: 315360000 },
-    { unit: 'year', secondsInUnit: 31536000 },
-    { unit: 'month', secondsInUnit: 2592000 },
-    { unit: 'week', secondsInUnit: 604800 },
-    { unit: 'day', secondsInUnit: 86400 },
-    { unit: 'hour', secondsInUnit: 3600 },
-    { unit: 'minute', secondsInUnit: 60 },
-    { unit: 'second', secondsInUnit: 1 },
+    { unit: 'millenium', secondsInUnit: 31536000000, format: prettifyExponentialNotation, plural: 'millennia' },
+    { unit: 'century', secondsInUnit: 3153600000, plural: 'centuries' },
+    { unit: 'decade', secondsInUnit: 315360000, plural: 'decades' },
+    { unit: 'year', secondsInUnit: 31536000, plural: 'years' },
+    { unit: 'month', secondsInUnit: 2592000, plural: 'months' },
+    { unit: 'week', secondsInUnit: 604800, plural: 'weeks' },
+    { unit: 'day', secondsInUnit: 86400, plural: 'days' },
+    { unit: 'hour', secondsInUnit: 3600, plural: 'hours' },
+    { unit: 'minute', secondsInUnit: 60, plural: 'minutes' },
+    { unit: 'second', secondsInUnit: 1, plural: 'seconds' },
   ];
 
   return _.chain(timeUnits)
-    .map(({ unit, secondsInUnit, format = _.identity }) => {
+    .map(({ unit, secondsInUnit, plural, format = _.identity }) => {
       const quantity = Math.floor(seconds / secondsInUnit);
       seconds %= secondsInUnit;
 
@@ -41,7 +41,7 @@ function getHumanFriendlyDuration({ seconds }: { seconds: number }) {
       }
 
       const formattedQuantity = format(quantity);
-      return `${formattedQuantity} ${unit}${quantity > 1 ? 's' : ''}`;
+      return `${formattedQuantity} ${quantity > 1 ? plural : unit}`;
     })
     .compact()
     .take(2)
diff --git a/src/tools/roman-numeral-converter/roman-numeral-converter.vue b/src/tools/roman-numeral-converter/roman-numeral-converter.vue
index d365cc5a..498c340c 100644
--- a/src/tools/roman-numeral-converter/roman-numeral-converter.vue
+++ b/src/tools/roman-numeral-converter/roman-numeral-converter.vue
@@ -36,7 +36,7 @@ const validationRoman = useValidation({
 });
 
 const { copy: copyRoman } = useCopy({ source: outputRoman, text: 'Roman number copied to the clipboard' });
-const { copy: copyArabic } = useCopy({ source: outputNumeral, text: 'Arabic number copied to the clipboard' });
+const { copy: copyArabic } = useCopy({ source: () => String(outputNumeral), text: 'Arabic number copied to the clipboard' });
 
 
 
diff --git a/src/tools/sql-prettify/sql-prettify.vue b/src/tools/sql-prettify/sql-prettify.vue
index 049d6c89..503cd15f 100644
--- a/src/tools/sql-prettify/sql-prettify.vue
+++ b/src/tools/sql-prettify/sql-prettify.vue
@@ -1,11 +1,11 @@
 
+
+
+  
+    
+
+    
+
+    
+      
+        {{ obfuscatedString }}
+      
+
+      
+         
+     
+  
 
diff --git a/src/tools/text-diff/index.ts b/src/tools/text-diff/index.ts
new file mode 100644
index 00000000..992acbae
--- /dev/null
+++ b/src/tools/text-diff/index.ts
@@ -0,0 +1,12 @@
+import { FileDiff } from '@vicons/tabler';
+import { defineTool } from '../tool';
+
+export const tool = defineTool({
+  name: 'Text diff',
+  path: '/text-diff',
+  description: 'Compare two texts and see the differences between them.',
+  keywords: ['text', 'diff', 'compare', 'string', 'text diff', 'code'],
+  component: () => import('./text-diff.vue'),
+  icon: FileDiff,
+  createdAt: new Date('2023-08-16'),
+});
diff --git a/src/tools/text-diff/text-diff.vue b/src/tools/text-diff/text-diff.vue
new file mode 100644
index 00000000..990f05b1
--- /dev/null
+++ b/src/tools/text-diff/text-diff.vue
@@ -0,0 +1,5 @@
+
+  
+     
+ 
diff --git a/src/tools/token-generator/token-generator.service.ts b/src/tools/token-generator/token-generator.service.ts
index 1885d249..3733a884 100644
--- a/src/tools/token-generator/token-generator.service.ts
+++ b/src/tools/token-generator/token-generator.service.ts
@@ -15,14 +15,12 @@ export function createToken({
   length?: number
   alphabet?: string
 }) {
-  const allAlphabet
-    = alphabet
-    ?? [
-      ...(withUppercase ? 'ABCDEFGHIJKLMOPQRSTUVWXYZ' : ''),
-      ...(withLowercase ? 'abcdefghijklmopqrstuvwxyz' : ''),
-      ...(withNumbers ? '0123456789' : ''),
-      ...(withSymbols ? '.,;:!?./-"\'#{([-|\\@)]=}*+' : ''),
-    ].join('');
+  const allAlphabet = alphabet ?? [
+    withUppercase ? 'ABCDEFGHIJKLMOPQRSTUVWXYZ' : '',
+    withLowercase ? 'abcdefghijklmopqrstuvwxyz' : '',
+    withNumbers ? '0123456789' : '',
+    withSymbols ? '.,;:!?./-"\'#{([-|\\@)]=}*+' : '',
+  ].join(''); ;
 
   return shuffleString(allAlphabet.repeat(length)).substring(0, length);
 }
diff --git a/src/tools/ulid-generator/index.ts b/src/tools/ulid-generator/index.ts
new file mode 100644
index 00000000..6a5408dd
--- /dev/null
+++ b/src/tools/ulid-generator/index.ts
@@ -0,0 +1,12 @@
+import { SortDescendingNumbers } from '@vicons/tabler';
+import { defineTool } from '../tool';
+
+export const tool = defineTool({
+  name: 'ULID generator',
+  path: '/ulid-generator',
+  description: 'Generate random Universally Unique Lexicographically Sortable Identifier (ULID).',
+  keywords: ['ulid', 'generator', 'random', 'id', 'alphanumeric', 'identity', 'token', 'string', 'identifier', 'unique'],
+  component: () => import('./ulid-generator.vue'),
+  icon: SortDescendingNumbers,
+  createdAt: new Date('2023-09-11'),
+});
diff --git a/src/tools/ulid-generator/ulid-generator.e2e.spec.ts b/src/tools/ulid-generator/ulid-generator.e2e.spec.ts
new file mode 100644
index 00000000..34473376
--- /dev/null
+++ b/src/tools/ulid-generator/ulid-generator.e2e.spec.ts
@@ -0,0 +1,23 @@
+import { expect, test } from '@playwright/test';
+
+const ULID_REGEX = /[0-9A-Z]{26}/;
+
+test.describe('Tool - ULID generator', () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto('/ulid-generator');
+  });
+
+  test('Has correct title', async ({ page }) => {
+    await expect(page).toHaveTitle('ULID generator - IT Tools');
+  });
+
+  test('the refresh button generates a new ulid', async ({ page }) => {
+    const ulid = await page.getByTestId('ulids').textContent();
+    expect(ulid?.trim()).toMatch(ULID_REGEX);
+
+    await page.getByTestId('refresh').click();
+    const newUlid = await page.getByTestId('ulids').textContent();
+    expect(ulid?.trim()).not.toBe(newUlid?.trim());
+    expect(newUlid?.trim()).toMatch(ULID_REGEX);
+  });
+});
diff --git a/src/tools/ulid-generator/ulid-generator.vue b/src/tools/ulid-generator/ulid-generator.vue
new file mode 100644
index 00000000..06e695ef
--- /dev/null
+++ b/src/tools/ulid-generator/ulid-generator.vue
@@ -0,0 +1,46 @@
+
+
+
+  
+    
+       Quantity: 
+      
+
+    
+
+    
+      {{ ulids }} 
+     
+
+    
+      
+        Refresh
+       
+      
+        Copy
+       
+    
+  
 
diff --git a/src/tools/uuid-generator/index.ts b/src/tools/uuid-generator/index.ts
index 2b4b3d34..ae5ae0da 100644
--- a/src/tools/uuid-generator/index.ts
+++ b/src/tools/uuid-generator/index.ts
@@ -5,7 +5,7 @@ export const tool = defineTool({
   name: 'UUIDs v4 generator',
   path: '/uuid-generator',
   description:
-    'A universally unique identifier (UUID) is a 128-bit number used to identify information in computer systems. The number of possible UUIDs is 16^32, which is 2^128 or about 3.4x10^38 (which is a lot !).',
+    'A Universally Unique Identifier (UUID) is a 128-bit number used to identify information in computer systems. The number of possible UUIDs is 16^32, which is 2^128 or about 3.4x10^38 (which is a lot!).',
   keywords: ['uuid', 'v4', 'random', 'id', 'alphanumeric', 'identity', 'token', 'string', 'identifier', 'unique'],
   component: () => import('./uuid-generator.vue'),
   icon: Fingerprint,
diff --git a/src/tools/uuid-generator/uuid-generator.vue b/src/tools/uuid-generator/uuid-generator.vue
index a393781b..e9f55e6b 100644
--- a/src/tools/uuid-generator/uuid-generator.vue
+++ b/src/tools/uuid-generator/uuid-generator.vue
@@ -34,7 +34,7 @@ const { copy } = useCopy({ source: uuids, text: 'UUIDs copied to the clipboard'
     />
 
     
-      
+      
         Copy
        
       
diff --git a/src/tools/wifi-qr-code-generator/index.ts b/src/tools/wifi-qr-code-generator/index.ts
new file mode 100644
index 00000000..ad0135c3
--- /dev/null
+++ b/src/tools/wifi-qr-code-generator/index.ts
@@ -0,0 +1,13 @@
+import { Qrcode } from '@vicons/tabler';
+import { defineTool } from '../tool';
+
+export const tool = defineTool({
+  name: 'WiFi QR Code generator',
+  path: '/wifi-qrcode-generator',
+  description:
+    'Generate and download QR-codes for quick connections to WiFi networks.',
+  keywords: ['qr', 'code', 'generator', 'square', 'color', 'link', 'low', 'medium', 'quartile', 'high', 'transparent', 'wifi'],
+  component: () => import('./wifi-qr-code-generator.vue'),
+  icon: Qrcode,
+  createdAt: new Date('2023-09-06'),
+});
diff --git a/src/tools/wifi-qr-code-generator/useQRCode.ts b/src/tools/wifi-qr-code-generator/useQRCode.ts
new file mode 100644
index 00000000..c8a7215c
--- /dev/null
+++ b/src/tools/wifi-qr-code-generator/useQRCode.ts
@@ -0,0 +1,146 @@
+import { type MaybeRef, get } from '@vueuse/core';
+import QRCode, { type QRCodeToDataURLOptions } from 'qrcode';
+import { isRef, ref, watch } from 'vue';
+
+export const wifiEncryptions = ['WEP', 'WPA', 'nopass', 'WPA2-EAP'] as const;
+export type WifiEncryption = typeof wifiEncryptions[number];
+
+// @see https://en.wikipedia.org/wiki/Extensible_Authentication_Protocol
+// for a list of available EAP methods. There are a lot (40!) of them.
+export const EAPMethods = [
+  'MD5',
+  'POTP',
+  'GTC',
+  'TLS',
+  'IKEv2',
+  'SIM',
+  'AKA',
+  'AKA\'',
+  'TTLS',
+  'PWD',
+  'LEAP',
+  'PSK',
+  'FAST',
+  'TEAP',
+  'EKE',
+  'NOOB',
+  'PEAP',
+] as const;
+export type EAPMethod = typeof EAPMethods[number];
+
+export const EAPPhase2Methods = [
+  'None',
+  'MSCHAPV2',
+] as const;
+export type EAPPhase2Method = typeof EAPPhase2Methods[number];
+
+interface IWifiQRCodeOptions {
+  ssid: MaybeRef
+  password: MaybeRef
+  eapMethod: MaybeRef
+  isHiddenSSID: MaybeRef
+  eapAnonymous: MaybeRef
+  eapIdentity: MaybeRef
+  eapPhase2Method: MaybeRef
+  color: { foreground: MaybeRef; background: MaybeRef }
+  options?: QRCodeToDataURLOptions
+}
+
+interface GetQrCodeTextOptions {
+  ssid: string
+  password: string
+  encryption: WifiEncryption
+  eapMethod: EAPMethod
+  isHiddenSSID: boolean
+  eapAnonymous: boolean
+  eapIdentity: string
+  eapPhase2Method: EAPPhase2Method
+}
+
+function escapeString(str: string) {
+  // replaces \, ;, ,, " and : with the same character preceded by a backslash
+  return str.replace(/([\\;,:"])/g, '\\$1');
+}
+
+function getQrCodeText(options: GetQrCodeTextOptions): string | null {
+  const { ssid, password, encryption, eapMethod, isHiddenSSID, eapAnonymous, eapIdentity, eapPhase2Method } = options;
+  if (!ssid) {
+    return null;
+  }
+  if (encryption === 'nopass') {
+    return `WIFI:S:${escapeString(ssid)};;`; // type can be omitted in that case, and password is not needed, makes the QR Code smaller
+  }
+  if (encryption !== 'WPA2-EAP' && password) {
+    // EAP has a lot of options, so we'll handle it separately
+    // WPA and WEP are pretty simple though.
+    return `WIFI:S:${escapeString(ssid)};T:${encryption};P:${escapeString(password)};${isHiddenSSID ? 'H:true' : ''};`;
+  }
+  if (encryption === 'WPA2-EAP' && password && eapMethod) {
+    // WPA2-EAP string is a lot more complex, first off, we drop the text if there is no identity, and it's not anonymous.
+    if (!eapIdentity && !eapAnonymous) {
+      return null;
+    }
+    // From reading, I could only find that a phase 2 is required for the PEAP method, I may be wrong though, I didn't read the whole spec.
+    if (eapMethod === 'PEAP' && !eapPhase2Method) {
+      return null;
+    }
+    // The string is built in the following order:
+    // 1. SSID
+    // 2. Authentication type
+    // 3. Password
+    // 4. EAP method
+    // 5. EAP phase 2 method
+    // 6. Identity or anonymous if checked
+    // 7. Hidden SSID if checked
+    const identity = eapAnonymous ? 'A:anon' : `I:${escapeString(eapIdentity)}`;
+    const phase2 = eapPhase2Method !== 'None' ? `PH2:${eapPhase2Method};` : '';
+    return `WIFI:S:${escapeString(ssid)};T:WPA2-EAP;P:${escapeString(password)};E:${eapMethod};${phase2}${identity};${isHiddenSSID ? 'H:true' : ''};`;
+  }
+  return null;
+}
+
+export function useWifiQRCode({
+  ssid,
+  password,
+  eapMethod,
+  isHiddenSSID,
+  eapAnonymous,
+  eapIdentity,
+  eapPhase2Method,
+  color: { background, foreground },
+  options,
+}: IWifiQRCodeOptions) {
+  const qrcode = ref('');
+  const encryption = ref('WPA');
+
+  watch(
+    [ssid, password, encryption, eapMethod, isHiddenSSID, eapAnonymous, eapIdentity, eapPhase2Method, background, foreground].filter(isRef),
+    async () => {
+      // @see https://github.com/zxing/zxing/wiki/Barcode-Contents#wi-fi-network-config-android-ios-11
+      // This is the full spec, there's quite a bit of logic to generate the string embeddedin the QR code.
+      const text = getQrCodeText({
+        ssid: get(ssid),
+        password: get(password),
+        encryption: get(encryption),
+        eapMethod: get(eapMethod),
+        isHiddenSSID: get(isHiddenSSID),
+        eapAnonymous: get(eapAnonymous),
+        eapIdentity: get(eapIdentity),
+        eapPhase2Method: get(eapPhase2Method),
+      });
+      if (text) {
+        qrcode.value = await QRCode.toDataURL(get(text).trim(), {
+          color: {
+            dark: get(foreground),
+            light: get(background),
+            ...options?.color,
+          },
+          errorCorrectionLevel: 'M',
+          ...options,
+        });
+      }
+    },
+    { immediate: true },
+  );
+  return { qrcode, encryption };
+}
diff --git a/src/tools/wifi-qr-code-generator/wifi-qr-code-generator.vue b/src/tools/wifi-qr-code-generator/wifi-qr-code-generator.vue
new file mode 100644
index 00000000..e6320d3e
--- /dev/null
+++ b/src/tools/wifi-qr-code-generator/wifi-qr-code-generator.vue
@@ -0,0 +1,153 @@
+
+
+
+  
+    
+      
+        
+        
+          
+            Hidden SSID
+           
+        
+        
+        
+        
+          
+            Anonymous?
+           
+        
+        
+        
+          
+             
+          
+             
+         
+      
+      
+        
+          
+          
+            Download qr-code
+           
+        
+      
+    
 
+ 
diff --git a/src/ui/c-buttons-select/c-buttons-select.demo.vue b/src/ui/c-buttons-select/c-buttons-select.demo.vue
new file mode 100644
index 00000000..dea15289
--- /dev/null
+++ b/src/ui/c-buttons-select/c-buttons-select.demo.vue
@@ -0,0 +1,14 @@
+
+
+
+   
diff --git a/src/ui/c-buttons-select/c-buttons-select.types.ts b/src/ui/c-buttons-select/c-buttons-select.types.ts
new file mode 100644
index 00000000..ccb110d4
--- /dev/null
+++ b/src/ui/c-buttons-select/c-buttons-select.types.ts
@@ -0,0 +1,5 @@
+import type { CSelectOption } from '../c-select/c-select.types';
+
+export type CButtonSelectOption = CSelectOption & {
+  tooltip?: string
+};
diff --git a/src/ui/c-buttons-select/c-buttons-select.vue b/src/ui/c-buttons-select/c-buttons-select.vue
new file mode 100644
index 00000000..38fff66f
--- /dev/null
+++ b/src/ui/c-buttons-select/c-buttons-select.vue
@@ -0,0 +1,59 @@
+
+
+
+  
+    
+      
+        
+          {{ option.label }}
+         
+       
+    
+   
+ 
diff --git a/src/ui/c-diff-editor/c-diff-editor.vue b/src/ui/c-diff-editor/c-diff-editor.vue
new file mode 100644
index 00000000..2aa29475
--- /dev/null
+++ b/src/ui/c-diff-editor/c-diff-editor.vue
@@ -0,0 +1,68 @@
+
+
+
+  
+ 
diff --git a/src/ui/c-key-value-list/c-key-value-list-item.vue b/src/ui/c-key-value-list/c-key-value-list-item.vue
new file mode 100644
index 00000000..d21ef5d1
--- /dev/null
+++ b/src/ui/c-key-value-list/c-key-value-list-item.vue
@@ -0,0 +1,27 @@
+
+
+
+  
+  
+    
+  
+    
+  
+    {{ item.placeholder ?? 'N/A' }}
+  
+  
+    
+ 
diff --git a/src/ui/c-key-value-list/c-key-value-list.types.ts b/src/ui/c-key-value-list/c-key-value-list.types.ts
new file mode 100644
index 00000000..40cc4ba4
--- /dev/null
+++ b/src/ui/c-key-value-list/c-key-value-list.types.ts
@@ -0,0 +1,9 @@
+export interface CKeyValueListItem {
+  label: string
+  value: string | string[] | number | boolean | undefined | null
+  hideOnNil?: boolean
+  placeholder?: string
+  showCopyButton?: boolean
+}
+
+export type CKeyValueListItems = CKeyValueListItem[];
diff --git a/src/ui/c-key-value-list/c-key-value-list.vue b/src/ui/c-key-value-list/c-key-value-list.vue
new file mode 100644
index 00000000..d8a2b001
--- /dev/null
+++ b/src/ui/c-key-value-list/c-key-value-list.vue
@@ -0,0 +1,21 @@
+
+
+
+  
+    
+      
+        {{ item.label }}
+      
+
+      
+    
+  
 
diff --git a/src/ui/c-modal/c-modal.vue b/src/ui/c-modal/c-modal.vue
index af92f01a..4d032bbf 100644
--- a/src/ui/c-modal/c-modal.vue
+++ b/src/ui/c-modal/c-modal.vue
@@ -1,11 +1,17 @@
 
+
+
+  
+    
+      {{ displayedValue ?? value }}
+       
+   
+ 
diff --git a/src/ui/c-tooltip/c-tooltip.demo.vue b/src/ui/c-tooltip/c-tooltip.demo.vue
new file mode 100644
index 00000000..d3852573
--- /dev/null
+++ b/src/ui/c-tooltip/c-tooltip.demo.vue
@@ -0,0 +1,17 @@
+
+  
+    
+      Hover me
+
+      
+        Tooltip content
+       
+     
+  
+
+  
+    
+      Hover me
+     
+  
+ 
diff --git a/src/ui/c-tooltip/c-tooltip.vue b/src/ui/c-tooltip/c-tooltip.vue
new file mode 100644
index 00000000..095315fb
--- /dev/null
+++ b/src/ui/c-tooltip/c-tooltip.vue
@@ -0,0 +1,31 @@
+
+
+
+  
+    
+      
+
+    
+      
+        {{ tooltip }}
+       
+    
+  
 
diff --git a/src/ui/color/color.models.ts b/src/ui/color/color.models.ts
index 5b4c79b5..0ae38a48 100644
--- a/src/ui/color/color.models.ts
+++ b/src/ui/color/color.models.ts
@@ -4,7 +4,7 @@ const clampHex = (value: number) => Math.max(0, Math.min(255, Math.round(value))
 
 function lighten(color: string, amount: number): string {
   const alpha = color.length === 9 ? color.slice(7) : '';
-  const num = parseInt(color.slice(1, 7), 16);
+  const num = Number.parseInt(color.slice(1, 7), 16);
 
   const r = clampHex(((num >> 16) & 255) + amount);
   const g = clampHex(((num >> 8) & 255) + amount);
diff --git a/src/utils/convert.ts b/src/utils/convert.ts
index c897543c..9eac1921 100644
--- a/src/utils/convert.ts
+++ b/src/utils/convert.ts
@@ -7,5 +7,5 @@ export function formatBytes(bytes: number, decimals = 2) {
   const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
   const i = Math.floor(Math.log(bytes) / Math.log(k));
 
-  return `${parseFloat((bytes / k ** i).toFixed(decimals))} ${sizes[i]}`;
+  return `${Number.parseFloat((bytes / k ** i).toFixed(decimals))} ${sizes[i]}`;
 }
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 8f5064d0..181ee9ae 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -1,14 +1,20 @@
 {
-  "extends": "@vue/tsconfig/tsconfig.web.json",
+  "extends": "@vue/tsconfig/tsconfig.json",
   "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "**/*.d.ts", "node_modules/vite-plugin-pwa/client.d.ts"],
   "exclude": ["src/**/__tests__/*"],
   "compilerOptions": {
-    "lib": ["ES2021"],
+    "lib": ["ES2022"],
+    "target": "es2022",
+    "module": "es2022",
+    "moduleResolution": "Node",
     "composite": true,
     "baseUrl": ".",
     "paths": {
       "@/*": ["./src/*"]
     },
-    "types": ["naive-ui/volar", "unplugin-icons/types/vue", "@intlify/unplugin-vue-i18n/messages"]
+    "types": ["naive-ui/volar", "@intlify/unplugin-vue-i18n/messages", "unplugin-icons/types/vue"],
+    "esModuleInterop": true,
+    "jsx": "preserve",
+    "skipLibCheck": true
   }
 }
diff --git a/tsconfig.vite-config.json b/tsconfig.vite-config.json
index d20d8726..b941809e 100644
--- a/tsconfig.vite-config.json
+++ b/tsconfig.vite-config.json
@@ -1,5 +1,5 @@
 {
-  "extends": "@vue/tsconfig/tsconfig.node.json",
+  "extends": "@tsconfig/node18/tsconfig.json",
   "include": ["vite.config.*"],
   "compilerOptions": {
     "composite": true,
diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json
index d080d611..0d7369ad 100644
--- a/tsconfig.vitest.json
+++ b/tsconfig.vitest.json
@@ -4,6 +4,6 @@
   "compilerOptions": {
     "composite": true,
     "lib": [],
-    "types": ["node", "jsdom"]
+    "types": ["node", "jsdom", "unplugin-icons/types/vue"]
   }
 }
diff --git a/vite.config.ts b/vite.config.ts
index 8e2e0836..00f90c33 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -25,7 +25,7 @@ export default defineConfig({
       runtimeOnly: true,
       compositionOnly: true,
       fullInstall: true,
-      include: [resolve(__dirname, 'locales/**'), resolve(__dirname, 'src/tools/*/locales/**')],
+      include: [resolve(__dirname, 'locales/**')],
     }),
     AutoImport({
       imports: [
@@ -106,4 +106,7 @@ export default defineConfig({
   test: {
     exclude: [...configDefaults.exclude, '**/*.e2e.spec.ts'],
   },
+  build: {
+    target: 'esnext',
+  },
 });